New script paper/forlop.py reads the same input file as diagnose.py. It checks whether the steering tolerates its own delay, places the binding link in the model as the link with a queue, calibrates the size and start of the increase so that the coordinated rule's first budget decision sees the numbers in the file, and runs simulate_endo month by month with and without measures. It also reports what the measure does to the rest of the chain: police effort, the share diverted, and whether the bottleneck moves to the links after it. The example file reproduces section 9.5 and table 10: +19 % now, +12 % after 12 months, capacity 1,31; 8,3 / 0,74 / 1,10 / 3,5 with measures and 68,7 / 0,65 / 1,26 without. The stability block reproduces section 9.3 with delay 11 and reactivity 3. The script declines to print a time course when the steering is unstable, when the forecast has not settled within the horizon, or when the model cannot build the backlog in the file before the first decision. sim.py is imported unchanged; paper/data is untouched. diagnose.py gives identical output for both input files; the bottleneck selection is now a function so forlop.py can reuse it, and true/false are rejected where a number is expected. Reviewed by an independent agent; its 14 findings are addressed. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
236 lines
13 KiB
Python
236 lines
13 KiB
Python
"""Flaskehalsdiagnose for straffesakskjeden fra egne tall.
|
|
|
|
Bruk (fra paper/):
|
|
python3 diagnose.py eksempel_domstol.json
|
|
|
|
Inndata er en JSON-fil med ett objekt per ledd i kjeden, i rekkefølge. Alle
|
|
strømmer i saker per måned, beholdninger i saker, tider i måneder:
|
|
|
|
{"ledd": [
|
|
{"navn": "Domstol",
|
|
"innstromning": 134, # saker inn per måned nå
|
|
"kapasitet": 105, # saker leddet kan avgjøre per måned, slik det virker nå
|
|
"restanse": 669, # saker i kø nå
|
|
"maal_tid": 3, # mål for saksbehandlingstid (mnd)
|
|
"prognose_innstromning": 134, # valgfritt: strømmen når balansen er gjenopprettet
|
|
"maks_andel_utenom": 0.2, # valgfritt: andel som kan ledes til forenklede reaksjoner
|
|
"restanse_for_12_mnd": 400, # valgfritt: gir observert vekst i restansen
|
|
"restanse_maal": 300, # valgfritt: akseptabel restanse (saker) i stedet for maal_tid;
|
|
# oppgis begge, brukes restanse_maal
|
|
"skjult_avgang": 0, # valgfritt: saker per måned som avsluttes uten behandling og
|
|
# som er regnet med i kapasitet (f.eks. henlagt pga. kapasitet);
|
|
# trekkes fra kapasiteten når utnyttelse og behov regnes ut
|
|
"innstromning_for_okning": 100} # valgfritt, brukes bare av forlop.py: saker inn per måned
|
|
# før strømmen begynte å øke
|
|
]}
|
|
|
|
Verktøyet følger trinn (c)-(e) i metoden (notatet, avsnitt 7.7 og 9.4-9.5):
|
|
(c) finn skranken som binder: høyest utnyttelse; er utnyttelsen lik på to desimaler,
|
|
avgjør restanseveksten i forhold til kapasiteten;
|
|
(d) velg tiltakstype etter Theory of Constraints: utnytt, underordne, utvid;
|
|
(e) dimensjoner kapasiteten etter prognosen for strømmen, ikke etter køen, med
|
|
den koordinerte regelen fra sim.py (simulate_endo, policy="koord"):
|
|
Z_ny = (xi_min * F_prognose + Q_overskudd / T_avvikling) / ups_star.
|
|
Forløpet over tid og stabilitetssjekken ligger i forlop.py, som leser samme fil.
|
|
Verktøyet regner ikke med kapasitet som er bevilget, men ennå ikke virker: oppgi
|
|
kapasiteten som virker nå, og trekk det som allerede er bevilget, fra behovet.
|
|
Kolonnen «Saksbeh.tid» er restanse delt på kapasitet, ikke målt saksbehandlingstid.
|
|
Restansen over målet regnes som restanse - maal_tid * kapasitet.
|
|
sim.py trekker i tillegg fra minste behandlingstid m_D; eksempelfila er satt
|
|
slik at overskuddet blir det samme som i sim.py i måned 24.
|
|
Bruker ingenting fra sim.py og endrer ikke paper/data/.
|
|
"""
|
|
import json
|
|
import sys
|
|
|
|
UPS_STAR = 0.95 # målutnyttelse ved dimensjonering (ENDO["ups_star"] i sim.py)
|
|
UPS_HI = 1.0 # bestill når utnyttelsen er over denne (ENDO["ups_hi"])
|
|
T_CLEAR = 24.0 # restansen avvikles over denne tiden, mnd (ENDO["T_clear"])
|
|
L1, L2 = 6.0, 24.0 # bestilt kapasitet virker lineært fra L1 til L2 mnd etter vedtak
|
|
|
|
TALLFELT = ("innstromning", "kapasitet", "restanse", "maal_tid", "prognose_innstromning",
|
|
"maks_andel_utenom", "restanse_for_12_mnd", "restanse_maal", "skjult_avgang",
|
|
"innstromning_for_okning")
|
|
|
|
|
|
def tall(x, d=2):
|
|
return f"{x:,.{d}f}".replace(",", " ").replace(".", ",")
|
|
|
|
|
|
def sjekk(data):
|
|
"""Stopp med en forståelig melding før regningen hvis inndata ikke kan brukes."""
|
|
if not isinstance(data, dict) or not isinstance(data.get("ledd"), list) or not data["ledd"]:
|
|
sys.exit('Feil i inndata: fila må ha en liste "ledd" med minst ett ledd.')
|
|
for nr, ledd in enumerate(data["ledd"], 1):
|
|
if not isinstance(ledd, dict):
|
|
sys.exit(f"Feil i inndata, ledd {nr}: hvert ledd må være et objekt med felt og verdier.")
|
|
hvor = f'ledd {nr} ({ledd.get("navn", "uten navn")})'
|
|
for felt in ("navn", "innstromning", "kapasitet", "restanse"):
|
|
if felt not in ledd:
|
|
sys.exit(f'Feil i inndata, {hvor}: feltet "{felt}" mangler.')
|
|
if not isinstance(ledd["navn"], str):
|
|
sys.exit(f'Feil i inndata, {hvor}: "navn" må være tekst i anførselstegn.')
|
|
if "maal_tid" not in ledd and "restanse_maal" not in ledd:
|
|
sys.exit(f'Feil i inndata, {hvor}: oppgi "maal_tid" eller "restanse_maal".')
|
|
for felt in TALLFELT:
|
|
if felt not in ledd:
|
|
continue
|
|
verdi = ledd[felt]
|
|
vist = json.dumps(verdi, ensure_ascii=False)
|
|
try:
|
|
if isinstance(verdi, bool):
|
|
raise TypeError
|
|
x = float(verdi)
|
|
except (TypeError, ValueError):
|
|
sys.exit(f'Feil i inndata, {hvor}: "{felt}" må være et tall '
|
|
f"(med punktum som desimaltegn), ikke {vist}.")
|
|
if not 0 <= x < float("inf"): # fanger også NaN
|
|
sys.exit(f'Feil i inndata, {hvor}: "{felt}" må være 0 eller et positivt, endelig tall, ikke {vist}.')
|
|
if float(ledd["kapasitet"]) - float(ledd.get("skjult_avgang", 0.0)) <= 0:
|
|
sys.exit(f"Feil i inndata, {hvor}: kapasiteten må være større enn 0, "
|
|
"også etter at skjult_avgang er trukket fra.")
|
|
if float(ledd.get("maks_andel_utenom", 0.0)) >= 1:
|
|
sys.exit(f'Feil i inndata, {hvor}: "maks_andel_utenom" er en andel og må være mindre enn 1 '
|
|
"(0.2 betyr 20 %).")
|
|
|
|
|
|
def analyser(ledd):
|
|
F = float(ledd["innstromning"])
|
|
skjult = float(ledd.get("skjult_avgang", 0.0))
|
|
Z_tot = float(ledd["kapasitet"]) # all avgang, også den skjulte
|
|
Z = Z_tot - skjult # avgang med behandling
|
|
Q = float(ledd["restanse"])
|
|
har_restansemaal = "restanse_maal" in ledd
|
|
maal_tid = float(ledd["maal_tid"]) if "maal_tid" in ledd and not har_restansemaal else None
|
|
Q_ref = float(ledd["restanse_maal"]) if har_restansemaal else maal_tid * Z_tot
|
|
ups = F / Z
|
|
# beholdning / avgangsrate; bare meningsfull når restansen er hele køen
|
|
tid = None if har_restansemaal else Q / Z_tot
|
|
if "restanse_for_12_mnd" in ledd:
|
|
vekst = (Q - float(ledd["restanse_for_12_mnd"])) / 12.0
|
|
vekst_kilde = "observert siste 12 mnd"
|
|
else:
|
|
vekst = max(F - Z_tot, 0.0) # det som ikke avsluttes, legger seg i kø
|
|
vekst_kilde = "innstrømning minus kapasitet"
|
|
return dict(navn=ledd["navn"], F=F, Z=Z, Q=Q, Q_ref=Q_ref, maal_tid=maal_tid, ups=ups, tid=tid,
|
|
vekst=vekst, vekst_kilde=vekst_kilde, skjult=skjult,
|
|
Fp=float(ledd.get("prognose_innstromning", F)),
|
|
har_prognose="prognose_innstromning" in ledd,
|
|
F_for=float(ledd["innstromning_for_okning"]) if "innstromning_for_okning" in ledd else None,
|
|
andel=float(ledd.get("maks_andel_utenom", 0.0)))
|
|
|
|
|
|
def skranke(rader):
|
|
"""Trinn (c): høyest utnyttelse, deretter raskest voksende restanse relativt til kapasiteten."""
|
|
return max(rader, key=lambda a: (round(a["ups"], 2), a["vekst"] / a["Z"]))
|
|
|
|
|
|
def binder(b):
|
|
return b["ups"] > UPS_HI or b["vekst"] > 0
|
|
|
|
|
|
def tiltak(a):
|
|
"""Trinn (d) og (e) for det bindende leddet."""
|
|
ut = []
|
|
xi_min = 1.0 - a["andel"]
|
|
Q_exc = max(a["Q"] - a["Q_ref"], 0.0) # restanse over målet
|
|
ut.append("1. Utnytt leddet: fjern tapt tid i skranken (ikke regnet her).")
|
|
if a["andel"] > 0:
|
|
ups_u = xi_min * a["Fp"] / a["Z"]
|
|
ut.append(f"2. Led saker utenom: med {tall(a['andel'] * 100, 0)} % til forenklede reaksjoner "
|
|
f"blir utnyttelsen {tall(ups_u)} (fra {tall(a['Fp'] / a['Z'])} "
|
|
+ ("med prognosen)." if a["har_prognose"] else "med innstrømningen nå)."))
|
|
if ups_u <= UPS_HI and Q_exc == 0:
|
|
ut.append(" Det rekker: kapasiteten trenger ikke utvides.")
|
|
return ut
|
|
else:
|
|
ut.append("2. Led saker utenom: ikke oppgitt at noen andel kan ledes utenom dette leddet.")
|
|
behov = (xi_min * a["Fp"] + Q_exc / T_CLEAR) / UPS_STAR
|
|
if behov * UPS_STAR > UPS_HI * a["Z"]: # som sim.py: bestill når behovet før marginen er over kapasiteten
|
|
okning = behov / a["Z"] - 1.0
|
|
grunnlag = (f"prognosen ({tall(a['Fp'], 1)} saker/mnd)" if a["har_prognose"]
|
|
else f"innstrømningen nå ({tall(a['Fp'], 1)} saker/mnd; ingen prognose oppgitt)")
|
|
ut.append(f"3. Utvid: dimensjoner etter {grunnlag}, ikke etter køen. "
|
|
f"Restanse over målet: {tall(Q_exc, 0)} saker, avviklet over {T_CLEAR:.0f} mnd.")
|
|
ut.append(f" Kapasitet: ({tall(xi_min)} x {tall(a['Fp'], 1)} + {tall(Q_exc, 0)}/{T_CLEAR:.0f}) / "
|
|
f"{tall(UPS_STAR)} = {tall(behov, 1)} saker/mnd, det vil si {tall(okning * 100, 0)} % mer"
|
|
+ (f" enn kapasiteten etter skjult avgang ({tall(a['Z'], 1)} saker/mnd)."
|
|
if a["skjult"] > 0 else "."))
|
|
ut.append(f" Bestilt kapasitet virker gradvis fra {L1:.0f} til {L2:.0f} mnd etter vedtak. "
|
|
"Trekk fra kapasitet som er bevilget, men ikke virker ennå."
|
|
+ (" Når restansen er avviklet, går andelen utenom tilbake, og behovet vurderes på nytt."
|
|
if a["andel"] > 0 else ""))
|
|
else:
|
|
ut.append("3. Utvid: ikke nødvendig med disse tallene.")
|
|
return ut
|
|
|
|
|
|
def les(fil):
|
|
try:
|
|
with open(fil, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except FileNotFoundError:
|
|
sys.exit(f"Fant ikke fila {fil}.")
|
|
except UnicodeDecodeError:
|
|
sys.exit(f"Fila {fil} er ikke lagret som UTF-8.")
|
|
except json.JSONDecodeError as e:
|
|
sys.exit(f"Fila {fil} er ikke gyldig JSON: feil på linje {e.lineno}, kolonne {e.colno}.")
|
|
except OSError:
|
|
sys.exit(f"Kunne ikke lese {fil}. Er det en mappe, eller mangler du lesetilgang?")
|
|
sjekk(data)
|
|
return data
|
|
|
|
|
|
def main(fil):
|
|
rader = [analyser(ledd) for ledd in les(fil)["ledd"]]
|
|
w = max(22, max(len(a["navn"]) for a in rader) + 2)
|
|
print("Trinn (c): utnyttelse og restanse i hvert ledd\n")
|
|
print(f"{'Ledd':<{w}}{'Utnyttelse':>11}{'Saksbeh.tid':>13}{'Mål':>6}{'Restansevekst/mnd':>19}")
|
|
for a in rader:
|
|
maal = tall(a['maal_tid'], 1) if a['maal_tid'] is not None else "-"
|
|
tid = tall(a['tid'], 1) if a['tid'] is not None else "-"
|
|
print(f"{a['navn']:<{w}}{tall(a['ups']):>11}{tid:>13}{maal:>6}"
|
|
f"{tall(a['vekst'], 0):>19}")
|
|
if any(a["tid"] is not None for a in rader):
|
|
print("(Saksbeh.tid er restanse delt på kapasitet, ikke målt saksbehandlingstid.)")
|
|
b = skranke(rader)
|
|
print()
|
|
if not binder(b):
|
|
vokser = [a["navn"] for a in rader if a["vekst"] > 0]
|
|
if vokser:
|
|
print(f"Ingen ledd er over full utnyttelse, men restansen vokser i: {', '.join(vokser)}. "
|
|
"Kontroller tallene for innstrømning og kapasitet der.")
|
|
else:
|
|
print(f"Ingen ledd binder: høyeste utnyttelse er {tall(b['ups'])} ({b['navn']}), "
|
|
"og ingen restanse vokser.")
|
|
return
|
|
print(f"Skranken som binder: {b['navn']} (utnyttelse {tall(b['ups'])}, restanse {tall(b['Q'], 0)} "
|
|
f"mot mål {tall(b['Q_ref'], 0)}; vekst: {b['vekst_kilde']}).")
|
|
like = [a["navn"] for a in rader if a is not b and round(a["ups"], 2) == round(b["ups"], 2)]
|
|
if like:
|
|
print(f"Samme utnyttelse på to desimaler: {', '.join(like)}. Verktøyet velger da leddet der "
|
|
"restansen vokser raskest i forhold til kapasiteten. Forskjellen er for liten til å skille leddene.")
|
|
andre = [f"{a['navn']} ({tall(a['ups'])})" for a in rader
|
|
if a is not b and (a["ups"] > UPS_HI or a["vekst"] > 0)]
|
|
if andre:
|
|
print(f"Også over full utnyttelse eller med voksende restanse: {', '.join(andre)}. "
|
|
"Skranken kan flytte seg dit når den første er løst.")
|
|
plass = next(i for i, a in enumerate(rader) if a is b)
|
|
nedstroms = [a["navn"] for a in rader[plass + 1:]]
|
|
if nedstroms:
|
|
print(f"Merk: i den grad {', '.join(nedstroms)} får sakene sine gjennom skranken, viser tallene der "
|
|
"bare det som slipper gjennom. Når den løses, kan innstrømningen dit øke.")
|
|
print("\nTrinn (d)-(e): tiltak etter Theory of Constraints, i denne rekkefølgen\n")
|
|
for linje in tiltak(b):
|
|
print(linje)
|
|
print()
|
|
if plass > 0:
|
|
print("Å bremse leddene foran skranken til dens takt er ikke et tiltak: "
|
|
"i modellens domstolseksempel gir det det dårligste utfallet for kriminaliteten.")
|
|
print("Kontroller så de rettsstatlige skrankene (trinn f): det er sakstyper, ikke prosentandeler, som flyttes.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) != 2:
|
|
sys.exit("Bruk: python3 diagnose.py <inndata.json>")
|
|
main(sys.argv[1])
|