feat: model core, launcher, reproduction check and the first two skills

The core in modell/kjerne/ is an unmodified copy of sim.py, diagnose.py and
forlop.py from den-dynamiske-rettsstaten @2badc25, pinned by sha256.
kjor.py is the single entry point; kontroll.py reproduces the note's 22 data
files and printed results. Skills: diagnose and forlop (Norwegian).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-11 13:53:32 +01:00
commit 464ad38b49
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
51 changed files with 11207 additions and 0 deletions

18
modell/kjerne/KILDE.json Normal file
View file

@ -0,0 +1,18 @@
{
"repo": "https://git.fromaitochitta.com/open/den-dynamiske-rettsstaten",
"commit": "2badc25e5134ee26309bd8c3bd008ac64afe55a4",
"filer": {
"sim.py": {
"kilde": "paper/sim.py",
"sha256": "96857a92492f5768bc5d1d98bec30f4292b603c1273913e69901b6a1b220a01e"
},
"diagnose.py": {
"kilde": "paper/diagnose.py",
"sha256": "e5a20c68615836dd854c1414346c7932037eefaf1adb29a18458dbd590a3367a"
},
"forlop.py": {
"kilde": "paper/forlop.py",
"sha256": "5b369e30f98783b712f76e5dc90bcea4f711603944690c4f19da01efd29c6e21"
}
}
}

236
modell/kjerne/diagnose.py Normal file
View file

@ -0,0 +1,236 @@
"""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])

527
modell/kjerne/forlop.py Normal file
View file

@ -0,0 +1,527 @@
"""Forløp over tid og stabilitetssjekk for leddet som binder, fra egne tall.
Bruk (fra paper/, med samme Python-miljø som sim.py):
../.venv/bin/python forlop.py eksempel_domstol.json
../.venv/bin/python forlop.py norge_2025.json "Tingrett, meddomsrett" # velg ledd selv
Leser samme fil som diagnose.py. To ting kan legges til:
{"styring": {"forsinkelse": 3, # måneder fra kriminalitetsbildet endrer seg til innsatsen
# er endret (statistikk + beslutning + iverksetting)
"reaktivitet": 3}, # prosent mer innsats per prosent avvik fra målet
"ledd": [{..., "innstromning_for_okning": 100}]} # saker inn per måned før strømmen økte
Uten "styring" brukes regime A i notatet (3 og 3). Uten "innstromning_for_okning"
antas leddet 95 % utnyttet før økningen, som i notatet.
Hva skriptet gjør:
1. Stabilitetssjekk: ligger forsinkelsen under den kritiske forsinkelsen for denne
reaktiviteten (notatet, proposisjon 1 og avsnitt 9.3; critical_delay i sim.py)?
2. Forløp: leddet som binder, legges inn som modellens ledd med kø (domstolen i
avsnitt 9.5), og den koordinerte regelen kjøres måned for måned. Størrelsen på
økningen settes slik at regelens første budsjettvedtak ser prognosen i fila.
Restansen legges inn direkte: forløpet starter i restansen i fila, på
tidspunktet tallene gjelder. «Nå» er første budsjettvedtak, tre måneder etter.
3. Resten av kjeden: politiets innsats, andelen ledet utenom, og hva flere avgjorte
saker betyr for leddene som står etter i fila.
Kapasitet, restanse og saksbehandlingstid følger tallene i fila. Kriminalitet,
tillit og politiets innsats følger modellens stiliserte parametere (tabell 3 i
notatet), som ikke er kalibrert på norske data.
Bruker sim.py uendret og skriver ingenting til paper/data/. For å kunne starte i
restansen i fila har skriptet sin egen utgave av regnestegene i simulate_endo
(simuler() under). Hver kjøring kontrollerer at den gir nøyaktig samme svar som
simulate_endo når restansen ikke legges inn.
"""
import json
import math
import re
import sys
import numpy as np
from scipy.optimize import brentq
import diagnose
from diagnose import tall
from sim import (CH0, DT, ENDO, P, UPS0, VARSIGMA, Omega, critical_delay, deriv_chain_xz,
init_chain, predict_F, simulate_endo)
J0 = P["gamma"] * P["Lam0"] * P["T0"] # modellens strøm før økningen (0,10 per måned)
NAA = 24.0 # modellmåneden for første budsjettvedtak etter økningen
OBS = NAA - ENDO["lagD"] # vedtaket bygger på tall som er lagD måneder gamle
SLUTT = 180.0 # som tabell 7 og 10 i notatet
LAM_MYK = P["Lam0"] - 0.05 # myk grense for tillit (0,75), som i mpc_step
K_GULV, T_GULV = 0.80, 0.50 # under dette (1 = før økningen) regnes forløpet ikke
UPS_FOR_MIN = ENDO["ups_lo"] + 0.01 # under dette bygger modellens regel ned kapasitet før økningen
UPS_FOR_MAKS = 0.98 # over dette bestiller den kapasitet før økningen
RADER = (-3, 0, 6, 12, 18, 24, 36, 48, 60, 72, 96, 120, 156)
def idx(mnd):
return int(round(mnd / DT))
def les_styring(data):
s = data.get("styring", {})
if not isinstance(s, dict):
sys.exit('Feil i inndata: "styring" må være et objekt med "forsinkelse" og "reaktivitet".')
ut = {}
for felt, standard, maks in (("forsinkelse", 3.0, 60.0), ("reaktivitet", 3.0, 50.0)):
verdi = s.get(felt, standard)
try:
x = float("nan") if isinstance(verdi, bool) else float(verdi)
except (TypeError, ValueError):
x = float("nan")
if not 0 < x <= maks:
sys.exit(f'Feil i inndata, styring: "{felt}" må være et tall større enn 0 og høyst {maks:.0f}, '
f"ikke {json.dumps(verdi, ensure_ascii=False)}.")
ut[felt] = x
ut["oppgitt"] = "styring" in data
return ut
def stabilitet(tau, kp):
"""Lineær stabilitet i referanselikevekten: x' = -a x - b x(t - tau)."""
c = P["gamma"] * P["Lam0"] # fjerningsrate per enhet innsats (0,10 per måned)
a = c * P["T0"] + P["delta"]
b = c * kp
tc = critical_delay(a, b)
print("Stabilitetssjekk: tåler styringen sin egen forsinkelse?\n")
print(f"Forsinkelse {tall(tau, 1)} mnd, reaktivitet {tall(kp, 1)} "
f"(10 % over målet gir {tall(kp * 10, 0)} % mer innsats).")
if math.isinf(tc):
print("Stabil uansett forsinkelse: reaksjonen er svakere enn systemets egen demping "
f"(reaktivitet høyst {tall(a / c, 1)}).")
return True
stabil = tau < tc
print(f"Kritisk forsinkelse: {tall(tc, 1)} mnd. " +
(f"Forsinkelsen er kortere, så styringen er stabil (margin {tall(tc - tau, 1)} mnd)." if stabil else
"Forsinkelsen er lengre, så styringen skaper svingninger selv: satsinger og kutt avløser "
f"hverandre. Ved stabilitetsgrensen er perioden om lag "
f"{tall(2 * math.pi / math.sqrt(b * b - a * a), 0)} mnd."))
if critical_delay(a, c * 50.0) < tau:
kp_maks = brentq(lambda k: critical_delay(a, c * k) - tau, a / c + 1e-6, 50.0)
print(f"Med denne forsinkelsen er største stabile reaktivitet {tall(kp_maks, 1)}; "
f"tommelfingerregelen gir under {tall(math.pi / (2 * c * tau), 1)}.")
if not stabil:
print(f"To veier ut: demp reaksjonen, eller kort forsinkelsen ned til under {tall(tc, 1)} mnd.")
print("(Regnet med modellens stiliserte tall: innsatsen fjerner 10 % av kriminaliteten per måned, "
"og 2 % faller fra av seg selv.)")
return stabil
def modell(b):
"""Skaler leddet til modellens enheter. Returnerer skala, kjede, regelparametere, målene
regelens første vedtak skal se (prognose og restanse over målet), og køen som svarer til
restansen i fila, alt i modellens enheter."""
Z_u = b["Z"]
antatt = b["F_for"] is None
F_for = UPS0 * Z_u if antatt else b["F_for"]
hva = (f"innstrømningen før økningen ({tall(F_for, 1)} saker/mnd"
+ (f", antatt {tall(UPS0 * 100, 0)} % av kapasiteten)" if antatt else ")"))
if not UPS_FOR_MIN * Z_u <= F_for <= UPS_FOR_MAKS * Z_u:
sys.exit(f"Forløpet forutsetter et ledd som var i balanse før økningen: {hva} må ligge mellom "
f"{tall(UPS_FOR_MIN * 100, 0)} og {tall(UPS_FOR_MAKS * 100, 0)} % av kapasiteten "
f"({tall(Z_u, 1)}). Utenfor det ville modellens regel ha endret kapasiteten allerede før "
"økningen.")
if b["Fp"] <= F_for:
sys.exit(f"Forløpet har ingenting å vise for {b['navn']}: prognosen ({tall(b['Fp'], 1)} saker/mnd) "
f"er ikke høyere enn {hva}.")
skala = F_for / J0 # saker per måned per modellenhet
Z = Z_u / skala
tau_ref = b["Q_ref"] / Z_u # målet som måneder av kapasiteten modellen bruker
if tau_ref <= 0:
sys.exit("Forløpet trenger et mål for saksbehandlingstid eller restanse som er større enn 0.")
m = tau_ref - J0 / (Z * (1.0 / tau_ref + VARSIGMA)) # som MD i sim.py: gir tau_ref før økningen
ch = dict(CH0, Z=Z, m=m)
e = dict(xi_min=1.0 - b["andel"])
# Modellen viser restansen som (m + Q/Z) * Z. Køen Q som gir restansen i fila, er derfor
# restansen minus m * Z; modellen kan ikke ha mindre restanse enn m * Z.
return dict(skala=skala, ch=ch, e=e, F_for=F_for, antatt=antatt, tau_ref=tau_ref,
F_maal=b["Fp"] / skala, Q_maal=max(b["Q"] - b["Q_ref"], 0.0) / skala,
Q_inn=max(b["Q"] / skala - m * Z, 0.0), Q_min=m * Z)
def simuler(policy, tau, p, ch, e, kp, tend, inn=None):
"""Samme regnesteg som simulate_endo i sim.py for policy "koord" og "sched" (uten
kvalitetssløyfe og uten tidsplaner for kapasitet og andel utenom), men køen kan settes
direkte: inn = (steg, dQ) legger dQ til køen i det tidssteget (køen blir aldri negativ). Uten
inn skal svaret være nøyaktig det samme som fra simulate_endo; kontroller_integrator() prøver
det i hver kjøring. Endres simulate_endo, må denne endres likt."""
e = dict(ENDO, **(e or {}))
n = int(tend / DT) + 1
t = np.arange(n) * DT
K = np.ones(n); U = np.full(n, p["U0"]); L = np.full(n, p["Lam0"]); T = np.full(n, p["T0"])
Q = np.zeros(n); tauD = np.zeros(n); JD = np.zeros(n); RD = np.zeros(n); Fa = np.zeros(n)
Z = np.full(n, ch["Z"]); xi = np.ones(n)
Q0, tau_ref = init_chain(dict(ch, Zfun=None, xifun=None), p)
Q[0] = Q0
lag = int(round(tau / DT)); lagD = int(round(e["lagD"] / DT))
xi_tgt = 1.0
binding = False
orders = []
Z_down = None
log = []
for k in range(n - 1):
if inn is not None and k == inn[0]:
Q[k] = max(Q[k] + inn[1], 0.0) # opp eller ned til restansen i fila
tk = t[k]
Kobs = K[k - lag] if k - lag >= 0 else 1.0
T[k] = np.clip(p["T0"] + kp * (Kobs - p["Kstar"]), 0.0, p["Tmax"])
i = k - lagD
if i >= 0:
Q_hat, F_hat = Q[i], Fa[i]
else:
Q_hat, F_hat = Q0, 0.1
at_month = abs(tk - round(tk)) < 1e-9
if policy == "koord":
Q_ref = (tau_ref - ch["m"]) * Z[k]
Q_exc = max(Q_hat - Q_ref, 0.0)
if at_month and k > 0:
binding = binding or F_hat > e["ups_hi"] * Z[k]
xi_tgt = float(np.clip((e["ups_star"] * Z[k] - Q_exc / e["T_clear"]) / max(F_hat, 1e-9),
e["xi_min"], 1.0)) if binding else 1.0
if at_month and k > 0 and abs(tk % e["period"]) < 1e-9:
pending = sum(dZ * (1 - np.clip((tk - a) / (b - a), 0, 1)) for a, b, dZ in orders)
Z_comm = Z[k] + pending
i_ = max(i, 0)
F1 = predict_F(K[i_], U[i_], L[i_], Q[i_], Omega(t[i_], p), p, ch, tau_ref, e["H_pred"], kp, lag)
need, surplus_ref = e["xi_min"] * F1 + Q_exc / e["T_clear"], F1
sig = f"F_pred={F1:.3f} Q_exc={Q_exc:.2f}"
if need > e["ups_hi"] * Z_comm:
dZ = e["kZ"] * (need / e["ups_star"] - Z_comm)
orders.append((tk + e["L1"], tk + e["L2"], dZ)); Z_down = None
log.append(f"mnd {tk:.0f}: bestill +{dZ / ch['Z'] * 100:.0f} % ({sig})")
elif surplus_ref < e["ups_lo"] * Z[k] and pending < 1e-9:
Z_down = surplus_ref / e["ups_star"]
log.append(f"mnd {tk:.0f}: bygg ned mot {Z_down / ch['Z']:.2f} ({sig})")
rate = sum(dZ / (b - a) for a, b, dZ in orders if a <= tk < b)
if Z_down is not None and rate == 0.0 and Z[k] > Z_down:
rate -= (Z[k] - Z_down) / e["tau_down"]
Z[k + 1] = Z[k] + DT * rate
xi[k + 1] = xi[k] + DT * (xi_tgt - xi[k]) / e["tau_xi"]
elif policy != "sched":
raise ValueError(policy)
dK, dU, dL, dQ, tauD[k], JD[k], RD[k], Fa[k] = deriv_chain_xz(
K[k], U[k], L[k], T[k], Q[k], Z[k], xi[k], 0.0, tk, p, ch, tau_ref, mu3=0.0)
K[k + 1] = K[k] + DT * dK
U[k + 1] = U[k] + DT * dU
L[k + 1] = np.clip(L[k] + DT * dL, 1e-4, 1 - 1e-4)
Q[k + 1] = max(Q[k] + DT * dQ, 0.0)
for arr in (T, tauD, JD, RD, Fa):
arr[-1] = arr[-2]
return dict(t=t, K=K, U=U, L=L, T=T, Q=Q, tauD=tauD, JD=JD, RD=RD, Z=Z, xi=xi, tau_ref=tau_ref, log=log)
def kontroller_integrator(tau, p, ch, e, kp):
"""Stopp hvis simuler() uten innlagt restanse ikke gir nøyaktig samme svar som simulate_endo."""
for policy in ("koord", "sched"):
egen = simuler(policy, tau, p, ch, e, kp, SLUTT)
fasit = simulate_endo(policy, tau=tau, p=p, ch=ch, e=e, kp=kp, tend=SLUTT)
like = (egen["log"] == fasit["log"] and egen["tau_ref"] == fasit["tau_ref"]
and all(np.array_equal(egen[n], fasit[n])
for n in ("t", "K", "U", "L", "T", "Q", "tauD", "JD", "RD", "Z", "xi")))
if not like:
sys.exit("Intern kontroll feilet: regnestegene i forlop.py (simuler) gir ikke lenger samme svar "
f'som simulate_endo i sim.py (policy "{policy}"). Er sim.py endret, må simuler() '
"oppdateres likt. Forløpet er ikke regnet.")
def sett_ved_vedtak(okning, steg, mo, tau, kp, H=ENDO["H_pred"]):
"""Det den koordinerte regelen ser ved første vedtak (sim.py, simulate_endo, budsjettsløyfen):
prognosen F1 og restansen over målet, begge fra tilstanden lagD måneder før. Returnerer også
køen modellen selv har bygd opp på det tidspunktet."""
p = dict(P, shock=okning, t_shock=(steg - 0.5) * DT)
r = simulate_endo("koord", tau=tau, p=p, ch=mo["ch"], e=mo["e"], kp=kp, tend=OBS)
i = idx(OBS)
F1 = predict_F(r["K"][i], r["U"][i], r["L"][i], r["Q"][i], Omega(r["t"][i], p), p, mo["ch"],
r["tau_ref"], H, kp, idx(tau))
Q_exc = max(r["Q"][i] - (r["tau_ref"] - mo["ch"]["m"]) * mo["ch"]["Z"], 0.0)
return F1, Q_exc, r["Q"][i]
def kalibrer(mo, tau, kp):
"""Finn økningen i pådraget, slik at første vedtak ser F_maal, og tidssteget den begynner, slik at
restansen modellen selv bygger opp, kommer nærmest Q_maal. Økningen må begynne etter at budsjettet
i måned 12 har sett sine tall, og før tallene til vedtaket i måned 24 er tatt ut. Returnerer
(økning, steg, kant), der kant er "tidlig" eller "sent" når restansen ikke kan treffes innenfor
det vinduet. Avviket som står igjen, dekkes ved at restansen i fila legges inn direkte (main)."""
def okning_for(steg):
f = lambda x: sett_ved_vedtak(x, steg, mo, tau, kp)[0] - mo["F_maal"]
lav, hoy = 1.0001, 3.0
if f(lav) > 0:
sys.exit("Forløpet har ingenting å vise: prognosen ligger for nær innstrømningen før økningen.")
if f(hoy) < 0:
sys.exit("Forløpet dekker ikke disse tallene: prognosen ligger for høyt over innstrømningen før "
f"økningen ({tall(mo['F_maal'] / J0, 1)} ganger; modellen rekker om lag 3).")
return brentq(f, lav, hoy, xtol=1e-6)
def avvik(steg):
x = okning_for(steg)
return x, sett_ved_vedtak(x, steg, mo, tau, kp)[1] - mo["Q_maal"]
tidlig, sent = idx(NAA - 12.0 - ENDO["lagD"]) + 1, idx(OBS) - 1
x_t, g_t = avvik(tidlig)
if g_t <= 0: # selv den lengste forhistorien gir for liten restanse
return x_t, tidlig, "tidlig"
x_s, g_s = avvik(sent)
if g_s >= 0: # selv den korteste gir for stor
return x_s, sent, "sent"
while sent - tidlig > 1:
midt = (tidlig + sent) // 2
x_m, g_m = avvik(midt)
if g_m > 0:
tidlig, x_t, g_t = midt, x_m, g_m
else:
sent, x_s, g_s = midt, x_m, g_m
return (x_t, tidlig, None) if abs(g_t) <= abs(g_s) else (x_s, sent, None)
def naar(mnd):
if mnd == 0:
return "nå"
return f"om {mnd:.0f} mnd" if mnd > 0 else f"for {-mnd:.0f} mnd siden"
def vedtak(r, mo):
"""Budsjettvedtakene i forløpet, fra loggen til simulate_endo. Samme nedbygging år etter år
vises én gang."""
ut = []
for linje in r["log"]:
t = re.match(r"mnd (\d+): bestill \+(\d+) %", linje)
if t:
ut.append((float(t.group(1)) - NAA, f"bestill {t.group(2)} % mer kapasitet", int(t.group(2))))
continue
t = re.match(r"mnd (\d+): bygg ned mot ([\d.]+)", linje)
if not t:
sys.exit(f"Kjenner ikke igjen vedtaket «{linje}» fra sim.py; forlop.py må oppdateres.")
tekst = ("bygg ned ved naturlig avgang mot om lag "
f"{tall(float(t.group(2)) * mo['ch']['Z'] * mo['skala'], 0)} saker/mnd")
if not (ut and ut[-1][1] == tekst):
ut.append((float(t.group(1)) - NAA, tekst, None))
return ut
def nokkeltall(r, Z0, fra):
t = r["t"]
return dict(tid=r["tauD"].max(), tillit=r["L"].min(), krim=r["K"][t >= fra].mean(),
kap=r["Z"].max() / Z0 - 1.0,
kapaar=np.trapezoid(np.maximum(r["Z"] - Z0, 0) / Z0, t) / 12)
def ikke_regnet(grunn):
print(f"Forløp: ikke regnet. {grunn}")
def main(fil, valgt=None):
data = diagnose.les(fil)
st = les_styring(data)
tau, kp = st["forsinkelse"], st["reaktivitet"]
rader = [diagnose.analyser(ledd) for ledd in data["ledd"]]
if valgt is None:
b = diagnose.skranke(rader)
else:
treff = [a for a in rader if a["navn"] == valgt]
if not treff:
sys.exit(f'Fant ikke leddet "{valgt}". Leddene i fila: ' + ", ".join(a["navn"] for a in rader) + ".")
b = treff[0]
stabil = stabilitet(tau, kp)
if not st["oppgitt"]:
print('(Fila har ikke "styring"; forsinkelse 3 mnd og reaktivitet 3 er regime A i notatet.)')
print()
if valgt is None and not diagnose.binder(b):
print(f"Forløp: ingen ledd binder (høyeste utnyttelse er {tall(b['ups'])}, {b['navn']}), "
"så det er ikke noe tiltak å følge over tid.")
return
if not stabil:
ikke_regnet("Når styringen selv skaper svingninger, finnes det ingen ny balanse å dimensjonere etter, "
"og prognosen regelen bruker, blir tilfeldig. Rett styringen først (trinn (e) i metoden "
"krever det), og kjør på nytt.")
return
mo = modell(b)
okning, steg, kant = kalibrer(mo, tau, kp)
s, ch = mo["skala"], mo["ch"]
Z0 = ch["Z"]
F1, _, Q_egen = sett_ved_vedtak(okning, steg, mo, tau, kp)
F2 = sett_ved_vedtak(okning, steg, mo, tau, kp, H=2 * ENDO["H_pred"])[0]
if abs(F2 - F1) > 0.01 * F1:
ikke_regnet("Stabilitetssjekken er bestått, men prognosen for strømmen har ikke falt til ro innen "
f"horisonten på {tall(ENDO['H_pred'], 0)} mnd ({tall(F1 * s, 1)} saker/mnd, mot "
f"{tall(F2 * s, 1)} med dobbel horisont): styringen er for nær grensen eller for treg. Da "
"finnes det ikke ett tall å dimensjonere etter. Prøv med kortere forsinkelse.")
return
Q_u = max(b["Q"] - b["Q_ref"], 0.0)
behov = ((1.0 - b["andel"]) * b["Fp"] + Q_u / diagnose.T_CLEAR) / diagnose.UPS_STAR
def bestilling(x): # som regelen: ingen bestilling når behovet før marginen er dekket
return x / b["Z"] - 1.0 if x * ENDO["ups_star"] > ENDO["ups_hi"] * b["Z"] else 0.0
# Restansen i fila legges inn i tidssteget tallene til første vedtak tas ut (OBS): køen flyttes
# fra det modellens egen forhistorie ga, til restansen i fila. Resten av tilstanden (kriminalitet,
# tillit, politiinnsats) kommer fra forhistorien. Kjøringen uten tiltak får samme flytting, så
# den starter høyere når regelen har ledet saker utenom i forhistorien (som i tabell 10).
p = dict(P, shock=okning, t_shock=(steg - 0.5) * DT)
kontroller_integrator(tau, p, ch, mo["e"], kp)
inn = (idx(OBS), mo["Q_inn"] - Q_egen)
r = simuler("koord", tau, p, ch, mo["e"], kp, SLUTT, inn=inn)
r0 = simuler("sched", tau, p, ch, mo["e"], kp, SLUTT, inn=inn)
K_min, T_min = float(r["K"][idx(OBS):].min()), float(r["T"][idx(OBS):].min())
if K_min < K_GULV or T_min < T_GULV:
ikke_regnet(f"Mens restansen i fila ({tall(b['Q'], 0)} saker, {tall(b['Q'] / b['Z'], 0)} måneders "
f"kapasitet) avvikles, faller kriminaliteten i modellen til {tall(max(K_min, 0.0))} og "
f"politiets innsats til {tall(T_min)} (1,00 = nivået før økningen). Skriptet regner ikke "
f"forløpet når kriminaliteten kommer under {tall(K_GULV)} eller innsatsen under "
f"{tall(T_GULV)}: modellen legger halvparten av virkningen av en sak til tidspunktet saken "
"avgjøres, og det holder ikke når restansen er mange ganger større enn strømmen. Bruk "
"diagnosen.")
return
alle = vedtak(r, mo)
if any(mnd < 0 for mnd, _, _ in alle):
sys.exit("Forløpet kan ikke legges med disse tallene: modellens regel gjør vedtak før økningen er "
"observert. Kontroller kapasiteten og innstrømningen før økningen.")
print(f"Forløp over tid for {'leddet som binder' if valgt is None else 'leddet du valgte'}: {b['navn']}\n")
print("Slik er tallene lagt inn i modellen:")
print("- Leddet regnes som modellens ledd med kø: det tar imot sakene politiets innsats gir "
"(domstolen i notatet).")
print(f"- Fra fila: kapasitet {tall(b['Z'], 1)} saker/mnd"
+ (" (etter skjult avgang)" if b["skjult"] > 0 else "")
+ f", prognose {tall(b['Fp'], 1)} saker/mnd"
+ ("" if b["har_prognose"] else " (innstrømningen nå; ingen prognose oppgitt)")
+ f", restanse {tall(b['Q'], 0)} ({tall(Q_u, 0)} over målet på {tall(b['Q_ref'], 0)}), "
f"høyst {tall(b['andel'] * 100, 0)} % ledet utenom.")
print(f"- Før økningen: {tall(mo['F_for'], 1)} saker/mnd inn, utnyttelse {tall(mo['F_for'] / b['Z'])}"
+ (' (antatt; oppgi "innstromning_for_okning" for å endre).' if mo["antatt"] else " (oppgitt)."))
print(f"- Modellen gjenskaper prognosen med en varig økning i det strukturelle pådraget på "
f"{tall((okning - 1) * 100, 0)} % som begynte for {tall(NAA - steg * DT, 0)} mnd siden"
+ ({"sent": " (det seneste modellen tillater)", "tidlig": " (det tidligste modellen tillater)"}[kant]
if kant else "") + ".")
print(f"- «Nå» er første budsjettvedtak etter økningen. Tallene det bygger på, er "
f"{tall(ENDO['lagD'], 0)} mnd gamle, som tallene i fila.")
start_u = (mo["Q_inn"] + mo["Q_min"]) * s # restansen forløpet starter i, i saker
egen_u = (Q_egen + mo["Q_min"]) * s # restansen modellens egen forhistorie ga
print(f"- Restansen er lagt inn direkte: forløpet starter i {tall(start_u, 0)} saker på tidspunktet "
f"tallene gjelder (for {tall(ENDO['lagD'], 0)} mnd siden)."
+ (f" Modellens egen forhistorie ville gitt {tall(egen_u, 0)}."
if abs(egen_u - start_u) > max(0.02 * b["Q_ref"], 1.0) else ""))
if start_u - b["Q"] > 0.5:
print(f"- Avvik: restansen i fila ({tall(b['Q'], 0)}) er lavere enn modellen kan ha. Med denne "
f"kapasiteten regner modellen minst {tall(mo['Q_min'] / Z0, 1)} mnd behandlingstid, altså "
f"{tall(start_u, 0)} saker.")
if start_u - egen_u > max(0.25 * b["Q_ref"], 1.0):
print(f"- Forbehold: en så stor restanse har vokst lenger enn modellens forhistorie på "
f"{tall(NAA - steg * DT, 0)} mnd. Kriminalitet, tillit og politiinnsats starter derfor som om "
"restansen var ny, og virkningen av lang saksbehandlingstid kommer i modellen først etter "
"start. I virkeligheten har den alt virket en stund.")
if b["andel"] > 0 and r["xi"][idx(NAA)] < 0.995:
print(f"- I modellens forhistorie har den koordinerte regelen alt begynt å lede saker utenom "
f"({tall((1 - r['xi'][idx(NAA)]) * 100, 0)} % nå).")
if K_min < 0.995 or T_min < 0.995:
print(f"- Forbehold: mens restansen avvikles, faller kriminaliteten i modellen til {tall(K_min)} og "
f"politiets innsats til {tall(T_min)} (1,00 = nivået før økningen). Modellen legger halvparten "
"av virkningen av en sak til tidspunktet saken avgjøres. Om det holder for gamle saker, er "
"usikkert.")
print("\nVedtak i forløpet (budsjettet vedtar én gang i året; bestilt kapasitet virker gradvis "
f"fra {tall(ENDO['L1'], 0)} til {tall(ENDO['L2'], 0)} mnd etter vedtak):")
for mnd, tekst, _ in alle:
print(f"- {naar(mnd)}: {tekst}")
if not alle:
print("- ingen: regelen bestiller ikke kapasitet med disse tallene.")
forste = next((pst for mnd, _, pst in alle if mnd == 0 and pst is not None), None)
diag = round(bestilling(behov) * 100)
if forste is not None and forste == diag:
print(f" Første vedtak er det samme som diagnose.py regner ut ({diag} %).")
elif forste is not None:
print(f" diagnose.py regner {diag} % for første vedtak; forskjellen er avrunding i modellens "
"prognose.")
if len([1 for _, _, pst in alle if pst is not None]) > 1:
print(" Senere bestillinger kommer fordi restansen har vokst, eller prognosen er endret, mens den "
"første kapasiteten bygges opp.")
print(f"\n{'Mnd fra nå':>10}{'Kapasitet':>11}{'Utenom':>8}{'Restanse':>10}{'Saksbeh.tid':>13}"
f"{'Politiinnsats':>15}{'Kriminalitet':>14}{'Tillit':>8}")
for mnd in RADER:
k = idx(mnd + NAA)
print(f"{mnd:>10}{tall(r['Z'][k] * s, 1):>11}{tall((1 - r['xi'][k]) * 100, 0) + ' %':>8}"
f"{tall(r['tauD'][k] * r['Z'][k] * s, 0):>10}{tall(r['tauD'][k], 1):>13}"
f"{tall(r['T'][k]):>15}{tall(r['K'][k]):>14}{tall(r['L'][k]):>8}")
print("(Kapasitet i saker/mnd, restanse i saker, saksbehandlingstid i mnd = restanse delt på kapasitet. "
"Politiinnsats og kriminalitet: 1 = nivået før økningen. Tillit: 0,80 før økningen.)")
fra = steg * DT
med, uten = nokkeltall(r, Z0, fra), nokkeltall(r0, Z0, fra)
print(f"\nMed og uten tiltak i kjeden, fra økningen begynte til {tall(SLUTT - NAA, 0)} mnd fra nå:\n")
print(f"{'':<34}{'Med tiltakene':>14}{'Uten':>10}")
print(f"{'Lengste saksbehandlingstid (mnd)':<34}{tall(med['tid'], 1):>14}{tall(uten['tid'], 1):>10}")
print(f"{'Laveste tillit':<34}{tall(med['tillit']):>14}{tall(uten['tillit']):>10}")
print(f"{'Kriminalitet, gjennomsnitt':<34}{tall(med['krim']):>14}{tall(uten['krim']):>10}")
print(f"{'Høyeste kapasitet':<34}{'+' + tall(med['kap'] * 100, 0) + ' %':>14}{'+0 %':>10}")
print(f"{'Kapasitetsår over utgangsnivået':<34}{tall(med['kapaar'], 1):>14}{tall(0, 1):>10}")
uten_u = (r0["Q"][idx(OBS)] + mo["Q_min"]) * s
if abs(uten_u - start_u) >= 1.0:
print(f"(Uten tiltak starter restansen i {tall(uten_u, 0)} saker, ikke {tall(start_u, 0)}: i den "
"kjøringen er ingen saker ledet utenom før nå.)")
if med["tillit"] < LAM_MYK:
print(f"Tilliten kommer under modellens myke grense ({tall(LAM_MYK)}). Trinn (f) peker da på "
"prosessuell kvalitet i tillegg.")
sent = r["t"] > SLUTT - 60.0
spenn = {n: (float(r[n][sent].min()), float(r[n][sent].max())) for n in ("K", "tauD", "Z")}
i_ro = (spenn["K"][1] - spenn["K"][0] <= 0.01
and spenn["tauD"][1] - spenn["tauD"][0] <= 0.05 * mo["tau_ref"]
and spenn["Z"][1] - spenn["Z"][0] <= 0.01 * Z0)
if i_ro:
print(f"Forløpet faller til ro: kriminaliteten ender på {tall(r['K'][-1])} og saksbehandlingstiden på "
f"{tall(r['tauD'][-1], 1)} mnd.")
ups_slutt = r["JD"][-1] / r["Z"][-1]
if ups_slutt < ENDO["ups_star"] - 0.03:
print(f"Kapasiteten ender høyere enn strømmen krever (utnyttelse {tall(ups_slutt)} mot målet "
f"{tall(ENDO['ups_star'])}): det som ble bestilt for å avvikle restansen, blir stående, fordi "
f"regelen først bygger ned under {tall(ENDO['ups_lo'])}.")
else:
print("Forløpet har ikke falt til ro ved slutten. De siste fem årene ligger kriminaliteten mellom "
f"{tall(spenn['K'][0])} og {tall(spenn['K'][1])}, saksbehandlingstiden mellom "
f"{tall(spenn['tauD'][0], 1)} og {tall(spenn['tauD'][1], 1)} mnd og kapasiteten mellom "
f"{tall(spenn['Z'][0] * s, 1)} og {tall(spenn['Z'][1] * s, 1)} saker/mnd. Sluttallene over er da "
"ikke en ny balanse.")
print("\nResten av kjeden:")
print(f"- Politiet: innsatsen går fra 1,00 til høyst {tall(r['T'].max())} og ender på {tall(r['T'][-1])}. "
"Å bremse politiet til leddets takt er ikke et tiltak (diagnose.py).")
if b["andel"] > 0:
utenom = 1.0 - r["xi"]
print(f"- Saker ledet utenom {b['navn']}: høyst {tall(utenom.max() * 100, 0)} % "
f"({naar(round(r['t'][int(np.argmax(utenom))] - NAA))}) og {tall(utenom[-1] * 100, 0)} % til "
"slutt. Hvor de sakene havner, er ikke regnet.")
R_data = r["RD"][idx(OBS) - 1] # steget før restansen legges inn: avgangen følger køen i modellen
topp, slutt = r["RD"].max() / R_data, r["RD"][-1] / R_data
print(f"- Ut av {b['navn']}: i modellen avgjør leddet på det meste {tall((topp - 1) * 100, 0)} % flere saker "
f"enn på tidspunktet tallene i fila gjelder ({naar(round(r['t'][int(np.argmax(r['RD']))] - NAA))}), "
f"og {tall((slutt - 1) * 100, 0)} % flere til slutt.")
plass = next(i for i, a in enumerate(rader) if a is b)
for a in rader[plass + 1:]:
if max(topp, slutt) <= 1.005:
print(f"- {a['navn']}: uendret innstrømning herfra.")
continue
ny_topp, ny_slutt = a["F"] * topp, a["F"] * slutt
dom = ("Leddet binder allerede, og presset øker." if a["ups"] > diagnose.UPS_HI else
"Skranken flytter seg dit." if ny_slutt > diagnose.UPS_HI * a["Z"] else
"Leddet er over full utnyttelse mens restansen avvikles, ikke varig."
if ny_topp > diagnose.UPS_HI * a["Z"] else "Leddet tar det unna.")
print(f"- {a['navn']}: får leddet sakene sine gjennom {b['navn']}, går innstrømningen fra "
f"{tall(a['F'], 1)} til om lag {tall(ny_topp, 1)} per mnd på det meste og {tall(ny_slutt, 1)} til "
f"slutt, mot kapasitet {tall(a['Z'], 1)}: utnyttelse {tall(ny_topp / a['Z'])} og "
f"{tall(ny_slutt / a['Z'])}. {dom}")
if not rader[plass + 1:]:
print(f"- Fila har ingen ledd etter {b['navn']}.")
print("\nLes forløpet slik: kapasitet, restanse og saksbehandlingstid følger tallene i fila. Politiinnsats, "
"kriminalitet og tillit følger modellens stiliserte parametere, som ikke er kalibrert på norske data. "
"Rekkefølgen og retningen er poenget, ikke tallene. Bare leddet som binder, har kø i modellen.")
if __name__ == "__main__":
if len(sys.argv) not in (2, 3):
sys.exit('Bruk: python forlop.py <inndata.json> ["navn på ledd"]')
main(*sys.argv[1:])

1077
modell/kjerne/sim.py Normal file

File diff suppressed because it is too large Load diff

589
modell/kjor.py Normal file
View file

@ -0,0 +1,589 @@
# Copyright (c) 2026 Kjell Tore Guttormsen. MIT License, see LICENSE.
"""Én inngang til modellen bak notatet «Den dynamiske rettsstaten».
Bruk:
python3 kjor.py [--data <mappe>] [--modell <mappe>] <kommando> [...]
Bare standardbiblioteket, Python 3.9 eller nyere. Kommandoene står i HJELP under.
Exit-koder: 0 ok, 1 forskjeller funnet av kontroll, 2 feil i inndata eller bruk,
3 miljøet mangler (numpy eller scipy).
Valg av Python for kommandoene som trenger numpy og scipy: (1) denne tolken hvis den har
numpy 2.0 eller nyere (numpy.trapezoid) og scipy; (2) ellers Python i den egne mappa
<data>/venv, som kjører samme kommando på nytt; (3) ellers exit-kode 3 med en melding om
hva som mangler. Ingenting installeres uten «miljo --opprett».
Grensesnitt for modulene i modell-mappa (scenario.py, kontroll.py, arbeidskopi.py):
hver har ``main(argv: list[str]) -> int``, der argv er argumentene etter kommandonavnet.
Før main kalles, ligger modell-mappa og modell-mappa/kjerne først i sys.path, slik at
``import sim`` gir kjernen i samme modell-mappe. ``sys.exit("melding")`` gir exit-kode 2
og meldingen uendret; ``sys.exit(n)`` gir exit-kode n.
"""
from __future__ import annotations
import importlib
import importlib.util
import json
import os
import platform
import shlex
import subprocess
import sys
import traceback
from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple, TypedDict
if TYPE_CHECKING:
from collections.abc import Callable
from types import ModuleType
HER = Path(__file__).resolve().parent
VENV_MERKE = "DYNAMISK_RETTSSTAT_VENV" # satt når kjor.py er startet på nytt med Python fra den egne mappa
KJERNEMODULER = ("sim", "diagnose", "forlop")
MODELLMODULER = ("scenario", "kontroll", "arbeidskopi")
OK, FORSKJELLER, FEIL_BRUK, MILJO_MANGLER = 0, 1, 2, 3
HJELP = """\
Bruk: python3 kjor.py [--data <mappe>] [--modell <mappe>] <kommando> [...]
Kommandoer:
diagnose <fil> finn leddet som binder, fra egne tall (trenger bare Python)
forlop <fil> ["ledd"] stabilitetssjekk og forløp måned for måned (numpy og scipy)
scenario <fil> [--ut <mappe>] kjør en scenariofil (numpy og scipy)
notatet [--ut <mappe>] kjør notatets simuleringer uendret, om lag 1-2 min (numpy og scipy)
kontroll [--rask] kontroller at modellen regner som notatet
miljo [--opprett] vis Python, numpy og scipy; --opprett installerer dem i en egen mappe
arbeidskopi <mappe> lag en kopi av modellen som du kan endre
Valg før kommandoen:
--data <mappe> mappa der den egne Python-mappa (venv) med numpy og scipy ligger
(standard ~/.dynamisk-rettsstat)
--modell <mappe> bruk modellen i en arbeidskopi i stedet for pluginens
Exit-koder: 0 ok, 1 kontroll fant forskjeller, 2 feil i inndata eller bruk,
3 numpy eller scipy mangler.
"""
TREGER_REGNEMILJO = ("forlop", "scenario", "notatet")
KOMMANDOER = ("diagnose", "forlop", "scenario", "notatet", "kontroll", "miljo", "arbeidskopi")
class Bruksfeil(Exception):
"""Feil i kommandolinjen. Meldingen er til brukeren og gir exit-kode 2."""
class Valg(NamedTuple):
"""Kommandolinjen, tolket."""
argv: list[str] # hele kommandolinjen, slik den kom inn
data: Path # mappa der den egne venv ligger
data_oppgitt: bool # om --data ble oppgitt med en brukbar verdi
modell: Path # modell-mappa som brukes (pluginens eller en arbeidskopi)
kommando: str
args: list[str] # argumentene etter kommandoen
class Beskrivelse(TypedDict):
"""Hva en Python-tolk har av numpy og scipy."""
python: str
sti: str
numpy: str | None
trapezoid: bool
scipy: str | None
class Tolk(NamedTuple):
"""Hvilken Python som skal regne: «denne», «venv» eller «mangler»."""
hvor: str
python: Path | None
mangler: list[str]
# ---------------- kommandolinjen ----------------
def standard_datamappe() -> Path:
"""Mappa som brukes når --data ikke er oppgitt."""
return Path.home() / ".dynamisk-rettsstat"
def datamappe(verdi: str | None) -> tuple[Path, bool]:
"""Tom verdi, eller en verdi med «${» (ikke satt inn av Claude), betyr «ikke oppgitt»."""
if verdi is None or not verdi.strip() or "${" in verdi:
return standard_datamappe(), False
return Path(verdi).expanduser().resolve(), True
def modellmappe(verdi: str | None) -> Path:
"""Modell-mappa: pluginens egen, eller en arbeidskopi (mappa selv eller mappa over modell/)."""
if verdi is None:
return HER
if not verdi.strip():
melding = "--modell må ha en mappe etter seg."
raise Bruksfeil(melding)
sti = Path(verdi).expanduser().resolve()
for kandidat in (sti, sti / "modell"):
if (kandidat / "kjerne").is_dir():
return kandidat
melding = f"Fant ingen modell i {sti}: mappa må ha undermappa kjerne/ eller modell/kjerne/."
raise Bruksfeil(melding)
def tolk_argumenter(argv: list[str]) -> Valg:
"""Tolk de globale valgene og finn kommandoen."""
verdier: dict[str, str | None] = {"--data": None, "--modell": None}
i = 0
while i < len(argv) and argv[i].startswith("-"):
arg = argv[i]
if arg in {"-h", "--hjelp", "--help"}:
return Valg(argv=argv, data=standard_datamappe(), data_oppgitt=False, modell=HER, kommando="hjelp",
args=[])
navn, likhetstegn, verdi = arg.partition("=")
if navn not in verdier:
melding = f"Ukjent valg {arg}. Gyldige valg før kommandoen: --data <mappe>, --modell <mappe>."
raise Bruksfeil(melding)
if likhetstegn:
i += 1
elif i + 1 < len(argv):
verdi = argv[i + 1]
i += 2
else:
melding = f"{navn} må ha en mappe etter seg."
raise Bruksfeil(melding)
verdier[navn] = verdi
if i >= len(argv):
melding = "Mangler kommando."
raise Bruksfeil(melding)
kommando, args = argv[i], argv[i + 1:]
if kommando not in KOMMANDOER and kommando != "_beskriv":
melding = f"Ukjent kommando «{kommando}». Gyldige kommandoer: {', '.join(KOMMANDOER)}."
raise Bruksfeil(melding)
data, oppgitt = datamappe(verdier["--data"])
return Valg(argv, data, oppgitt, modellmappe(verdier["--modell"]), kommando, args)
# ---------------- Python-miljøet ----------------
def beskriv_denne() -> Beskrivelse:
"""Python, numpy og scipy i tolken som kjører nå."""
ut = Beskrivelse(python=platform.python_version(), sti=sys.executable, numpy=None, trapezoid=False, scipy=None)
try:
numpy = importlib.import_module("numpy")
except ImportError:
pass
else:
ut["numpy"] = str(getattr(numpy, "__version__", "ukjent versjon"))
ut["trapezoid"] = hasattr(numpy, "trapezoid")
try:
importlib.import_module("scipy.optimize")
scipy = importlib.import_module("scipy")
except ImportError:
pass
else:
ut["scipy"] = str(getattr(scipy, "__version__", "ukjent versjon"))
return ut
def mangler(beskrivelse: Beskrivelse) -> list[str]:
"""Det som mangler for å regne med numpy og scipy, i ord."""
ut = []
if beskrivelse["numpy"] is None:
ut.append("numpy")
elif not beskrivelse["trapezoid"]:
ut.append(f"numpy 2.0 eller nyere (har {beskrivelse['numpy']})")
if beskrivelse["scipy"] is None:
ut.append("scipy")
return ut
def beskriv_tolk(python: Path) -> Beskrivelse | None:
"""Beskriv en annen Python-tolk ved å la den kjøre denne fila. None hvis det ikke går."""
try:
resultat = subprocess.run(
[str(python), str(Path(__file__).resolve()), "_beskriv"],
capture_output=True, text=True, encoding="utf-8", check=False,
)
except OSError:
return None
if resultat.returncode != 0:
return None
try:
data = json.loads(resultat.stdout.strip().splitlines()[-1])
except (ValueError, IndexError):
return None
return Beskrivelse(
python=str(data["python"]), sti=str(data["sti"]), numpy=data["numpy"],
trapezoid=bool(data["trapezoid"]), scipy=data["scipy"],
)
def venv_mappe(data: Path) -> Path:
"""Den egne Python-mappa med numpy og scipy."""
return data / "venv"
def venv_python(data: Path) -> Path:
"""Python-tolken i den egne mappa."""
if os.name == "nt":
return venv_mappe(data) / "Scripts" / "python.exe"
return venv_mappe(data) / "bin" / "python"
def velg_tolk(data: Path) -> Tolk:
"""Velg Python for kommandoene som trenger numpy og scipy."""
mangel = mangler(beskriv_denne())
if not mangel:
return Tolk("denne", Path(sys.executable), [])
kandidat = venv_python(data)
if kandidat.exists() and os.environ.get(VENV_MERKE) != "1":
return Tolk("venv", kandidat, mangel)
return Tolk("mangler", None, mangel)
def kjor_paa_nytt(python: Path, argv: list[str]) -> int:
"""Kjør samme kommando med en annen Python og gi videre exit-koden."""
sys.stdout.flush()
miljo = dict(os.environ)
miljo[VENV_MERKE] = "1"
return subprocess.run([str(python), str(Path(__file__).resolve()), *argv], env=miljo, check=False).returncode
def kommando_tekst(valg: Valg, *args: str) -> str:
"""Kommandolinjen brukeren kan skrive for å kjøre kjor.py med samme --data."""
deler = ["python3", str(Path(__file__).resolve())]
if valg.data_oppgitt:
deler += ["--data", str(valg.data)]
return " ".join(shlex.quote(d) for d in [*deler, *args])
def melding_mangler(valg: Valg, tolk: Tolk) -> str:
"""Norsk melding for exit-kode 3."""
linjer = [
f"«{valg.kommando}» trenger numpy (2.0 eller nyere) og scipy. Mangler: {', '.join(tolk.mangler)}.",
f"Python som ble brukt: {sys.executable} ({platform.python_version()}).",
]
if os.environ.get(VENV_MERKE) == "1":
linjer.append(f"Den egne mappa {venv_mappe(valg.data)} mangler dem også. Kommandoen under installerer "
"dem der på nytt fra PyPI (krever internett):")
else:
linjer.append(f"Fant ingen egen mappe med numpy og scipy i {venv_mappe(valg.data)}. Kommandoen under "
"lager den og installerer numpy og scipy fra PyPI der (krever internett):")
linjer.append(f" {kommando_tekst(valg, 'miljo', '--opprett')}")
linjer.append("Ingenting installeres uten at du ber om det. diagnose virker uten numpy og scipy.")
return "\n".join(linjer)
# ---------------- å kjøre kjernen og modulene ----------------
def forbered_stier(modell: Path) -> None:
"""Legg modell-mappa og kjernen først i sys.path, og glem moduler lastet fra en annen mappe."""
sys.dont_write_bytecode = True # ingen __pycache__ i plugin-mappa
for navn in (*KJERNEMODULER, *MODELLMODULER):
sys.modules.pop(navn, None)
fjern = {str(modell), str(modell / "kjerne")}
if modell != HER:
fjern.add(str(HER))
sys.path[:] = [sti for sti in sys.path if str(Path(sti or ".").resolve()) not in fjern]
sys.path[:0] = [str(modell / "kjerne"), str(modell)]
def exit_kode(kode: object) -> int:
"""Exit-koden for et sys.exit(kode) fra kjernen eller en modul."""
if kode is None:
return OK
if isinstance(kode, int):
return kode
sys.stdout.flush()
print(kode, file=sys.stderr)
return FEIL_BRUK
def fang(funksjon: Callable[[], object], hva: str) -> int:
"""Kjør funksjonen og gjør sys.exit og uventede feil om til exit-koder."""
try:
resultat = funksjon()
except SystemExit as stopp:
return exit_kode(stopp.code)
except Exception: # noqa: BLE001 - alt som ikke er SystemExit, er en feil i koden, ikke i inndata
sys.stdout.flush()
traceback.print_exc()
print(f"Uventet feil i {hva} (se over).", file=sys.stderr)
return FEIL_BRUK
if isinstance(resultat, int) and not isinstance(resultat, bool):
return resultat
return OK
def last_kjerne(modell: Path, navn: str) -> ModuleType:
"""Last en kjernemodul fra modell-mappa/kjerne."""
forbered_stier(modell)
return importlib.import_module(navn)
def last_modul(modell: Path, navn: str) -> ModuleType | None:
"""Last modell-mappa/<navn>.py. None hvis fila ikke finnes."""
fil = modell / f"{navn}.py"
if not fil.is_file():
return None
forbered_stier(modell)
spec = importlib.util.spec_from_file_location(navn, fil)
if spec is None or spec.loader is None:
return None
modul = importlib.util.module_from_spec(spec)
sys.modules[navn] = modul
spec.loader.exec_module(modul)
return modul
def kjor_modul(valg: Valg, navn: str) -> int:
"""Kall main(argv) i modell-mappa/<navn>.py med argumentene etter kommandoen."""
fil = valg.modell / f"{navn}.py"
if not fil.is_file():
print(f"Kommandoen «{valg.kommando}» er ikke på plass i denne modellen ennå: fant ikke {fil}.",
file=sys.stderr)
return FEIL_BRUK
def start() -> object:
main = getattr(last_modul(valg.modell, navn), "main", None)
if not callable(main):
sys.exit(f"{fil} mangler funksjonen main(argv).")
return main(list(valg.args))
return fang(start, f"{navn}.py")
# ---------------- kommandoene ----------------
def kommando_diagnose(valg: Valg) -> int:
"""Kjernens diagnose.main."""
if len(valg.args) != 1:
melding = "Bruk: kjor.py diagnose <inndata.json>"
raise Bruksfeil(melding)
diagnose = last_kjerne(valg.modell, "diagnose")
return fang(lambda: diagnose.main(valg.args[0]), "diagnose.py")
def kommando_forlop(valg: Valg) -> int:
"""Kjernens forlop.main."""
if len(valg.args) not in {1, 2}:
melding = 'Bruk: kjor.py forlop <inndata.json> ["navn på ledd"]'
raise Bruksfeil(melding)
forlop = last_kjerne(valg.modell, "forlop")
return fang(lambda: forlop.main(*valg.args), "forlop.py")
def kommando_notatet(valg: Valg) -> int:
"""Kjør kjernens sim.py uendret, med ut-mappa som arbeidsmappe (sim.py skriver data/ der)."""
ut = Path.cwd() / "resultater" / "notatet"
args = list(valg.args)
if args:
if len(args) != 2 or args[0] != "--ut": # noqa: PLR2004 - «--ut <mappe>»
melding = "Bruk: kjor.py notatet [--ut <mappe>]"
raise Bruksfeil(melding)
ut = Path(args[1]).expanduser()
ut = ut.resolve()
if ut == valg.modell or valg.modell in ut.parents:
melding = f"Ut-mappa {ut} ligger inne i modell-mappa {valg.modell}. Velg en annen med --ut <mappe>."
raise Bruksfeil(melding)
if ut.exists() and not ut.is_dir():
melding = f"{ut} finnes og er ikke en mappe. Velg en annen med --ut <mappe>."
raise Bruksfeil(melding)
ut.mkdir(parents=True, exist_ok=True)
print(f"Kjører notatets simuleringer (kjerne/sim.py, uendret) i {ut}. Det tar om lag 1-2 minutter.\n",
flush=True)
miljo = dict(os.environ, PYTHONIOENCODING="utf-8", PYTHONDONTWRITEBYTECODE="1", PYTHONUNBUFFERED="1")
utskrift = ut / "sim_utskrift.txt"
with utskrift.open("w", encoding="utf-8") as logg, subprocess.Popen(
[sys.executable, str(valg.modell / "kjerne" / "sim.py")],
cwd=str(ut), stdout=subprocess.PIPE, text=True, encoding="utf-8", env=miljo,
) as prosess:
if prosess.stdout is not None:
for linje in prosess.stdout:
sys.stdout.write(linje)
logg.write(linje)
if prosess.returncode != 0:
print(f"sim.py stoppet med exit-kode {prosess.returncode}.", file=sys.stderr)
return FEIL_BRUK
antall = len(list((ut / "data").glob("*.dat")))
print(f"\nFerdig: {antall} datafiler i {ut / 'data'}, utskriften i {utskrift}.")
print("«kontroll» sammenligner pluginens egen kjøring med notatet.")
return OK
def laaste_versjoner(modell: Path) -> dict[str, str]:
"""Notatets låste versjoner fra modell/requirements.txt."""
ut = {}
fil = modell / "requirements.txt"
if fil.is_file():
for linje in fil.read_text(encoding="utf-8").splitlines():
navn, skille, versjon = linje.split("#")[0].strip().partition("==")
if skille:
ut[navn.strip().lower()] = versjon.strip()
return ut
def vis_tolk(beskrivelse: Beskrivelse, laast: dict[str, str]) -> list[str]:
"""Linjer som beskriver Python, numpy og scipy i en tolk."""
linjer = [f" Python {beskrivelse['python']} ({beskrivelse['sti']})"]
for navn in ("numpy", "scipy"):
versjon = beskrivelse["numpy"] if navn == "numpy" else beskrivelse["scipy"]
notatet = f" (notatet: {laast[navn]})" if navn in laast else ""
linjer.append(f" {navn}: {versjon or 'mangler'}{notatet}")
return linjer
def samme_som_notatet(beskrivelse: Beskrivelse, laast: dict[str, str]) -> bool:
"""Sjekk om tolken har notatets låste versjoner av numpy og scipy."""
return beskrivelse["numpy"] == laast.get("numpy") and beskrivelse["scipy"] == laast.get("scipy")
def rapport_miljo(valg: Valg) -> None:
"""Skriv ut hva denne Python og den egne mappa har."""
laast = laaste_versjoner(valg.modell)
denne = beskriv_denne()
print("Python som kjører nå:")
print("\n".join(vis_tolk(denne, laast)))
vpy = venv_python(valg.data)
print(f"\nEgen mappe for numpy og scipy: {venv_mappe(valg.data)}")
venv: Beskrivelse | None = None
if not vpy.exists():
print(" finnes ikke. «miljo --opprett» lager den og installerer numpy og scipy fra PyPI "
"(krever internett).")
else:
venv = beskriv_tolk(vpy)
if venv is None:
print(f" finnes, men {vpy} lot seg ikke starte. «miljo --opprett» prøver å lage den på nytt.")
else:
print("\n".join(vis_tolk(venv, laast)))
print()
regner = None
if not mangler(denne):
regner = denne
print("forlop, scenario, notatet og kontroll regner med Python som kjører nå.")
elif venv is not None and not mangler(venv):
regner = venv
print("forlop, scenario, notatet og kontroll regner med Python i den egne mappa.")
else:
hva = ", ".join(mangler(venv if venv is not None else denne))
print(f"forlop, scenario, notatet og kontroll kan ikke regne nå: {hva} mangler.")
if regner is not None:
if samme_som_notatet(regner, laast):
print("Versjonene av numpy og scipy er de samme som notatets, så kontroll kan gi bit for bit.")
else:
print("Versjonene av numpy og scipy er ikke de samme som notatets: kontroll sammenligner da "
"med toleranse, ikke bit for bit.")
def kjor_prosess(kommando: list[str]) -> int:
"""Kjør et program (pip, venv) med utskriften rett til skjermen. Exit-koden tilbake."""
sys.stdout.flush()
try:
return subprocess.run(kommando, check=False).returncode
except OSError as feil:
print(f"Kunne ikke starte {kommando[0]}: {feil}", file=sys.stderr)
return 1
def opprett_miljo(valg: Valg) -> int:
"""Lag den egne mappa og installer numpy og scipy: låste versjoner først, ellers nyeste."""
mappe = venv_mappe(valg.data)
vpy = venv_python(valg.data)
if not vpy.exists():
print(f"Lager egen Python-mappa {mappe} med {sys.executable} ({platform.python_version()}).")
valg.data.mkdir(parents=True, exist_ok=True)
if kjor_prosess([sys.executable, "-m", "venv", str(mappe)]) != 0 or not vpy.exists():
print(f"Kunne ikke lage {mappe}. På noen Linux-systemer må pakken python3-venv installeres først.",
file=sys.stderr)
return MILJO_MANGLER
pip = [str(vpy), "-m", "pip", "install", "--disable-pip-version-check"]
krav = valg.modell / "requirements.txt"
laast = laaste_versjoner(valg.modell)
print(f"\nInstallerer notatets versjoner ({', '.join(f'{n} {v}' for n, v in laast.items())}) fra PyPI.")
toleranse = False
if kjor_prosess([*pip, "-r", str(krav)]) != 0:
print("\nNotatets låste versjoner lot seg ikke installere for denne Python-versjonen. "
"Prøver numpy 2.0 eller nyere og scipy.")
toleranse = True
if kjor_prosess([*pip, "numpy>=2.0", "scipy"]) != 0:
print(f"\nInstallasjonen feilet. Se meldingene over. Mappa {mappe} kan slettes.", file=sys.stderr)
return MILJO_MANGLER
print()
rapport_miljo(valg)
if toleranse:
print("\nNumpy og scipy er ikke notatets låste versjoner. Resultatene kontrolleres da med toleranse, "
"ikke bit for bit.")
return OK
def kommando_miljo(valg: Valg) -> int:
"""Vis miljøet, eller lag den egne mappa med --opprett."""
if valg.args == ["--opprett"]:
return opprett_miljo(valg)
if valg.args:
melding = "Bruk: kjor.py miljo [--opprett]"
raise Bruksfeil(melding)
rapport_miljo(valg)
return OK
def kommando_beskriv(_valg: Valg) -> int:
"""Intern kommando for beskriv_tolk(): skriv beskrivelsen av denne tolken som JSON."""
print(json.dumps(beskriv_denne()))
return OK
def kommando_hjelp(_valg: Valg) -> int:
"""Skriv bruken."""
print(HJELP, end="")
return OK
def kommando_regner(valg: Valg) -> int:
"""Kommandoene som trenger numpy og scipy: velg Python først (se toppen av fila)."""
if valg.kommando == "scenario" and not (valg.modell / "scenario.py").is_file():
return kjor_modul(valg, "scenario") # sier at kommandoen ikke er på plass ennå
if valg.kommando == "forlop" and valg.args and not Path(valg.args[0]).exists():
print(f"Fant ikke fila {valg.args[0]}.", file=sys.stderr)
return FEIL_BRUK
tolk = velg_tolk(valg.data)
if tolk.hvor == "venv" and tolk.python is not None:
return kjor_paa_nytt(tolk.python, valg.argv)
if tolk.hvor == "mangler" and valg.kommando != "kontroll": # kontroll gjør det den kan uten
print(melding_mangler(valg, tolk), file=sys.stderr)
return MILJO_MANGLER
handlinger: dict[str, Callable[[Valg], int]] = {
"forlop": kommando_forlop, "notatet": kommando_notatet,
"scenario": lambda v: kjor_modul(v, "scenario"), "kontroll": lambda v: kjor_modul(v, "kontroll"),
}
return handlinger[valg.kommando](valg)
def utfor(valg: Valg) -> int:
"""Utfør kommandoen."""
handlinger: dict[str, Callable[[Valg], int]] = {
"hjelp": kommando_hjelp, "_beskriv": kommando_beskriv,
"diagnose": kommando_diagnose, "miljo": kommando_miljo,
"arbeidskopi": lambda v: kjor_modul(v, "arbeidskopi"),
}
return handlinger.get(valg.kommando, kommando_regner)(valg)
def main(argv: list[str]) -> int:
"""Inngangen. Returnerer exit-koden."""
try:
if not argv:
print(HJELP, end="", file=sys.stderr)
return FEIL_BRUK
return utfor(tolk_argumenter(argv))
except Bruksfeil as feil:
sys.stdout.flush()
print(f"{feil}\nSkriv «python3 kjor.py --hjelp» for bruk.", file=sys.stderr)
return FEIL_BRUK
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

432
modell/kontroll.py Normal file
View file

@ -0,0 +1,432 @@
# Copyright (c) 2026 Kjell Tore Guttormsen. MIT License, see LICENSE.
"""Gjenskapingskontroll: sjekk at modellen i denne mappa regner som notatet.
Bruk:
python3 kjor.py kontroll [--rask]
python3 kontroll.py [--rask] (med en Python som har numpy og scipy)
Fire kontroller, hver med én linje dom og detaljer bare ved avvik:
1. Kjernen: sha256 for filene i kjerne/ mot kjerne/KILDE.json.
2. diagnose.py på begge eksempelfilene mot fasit/ (tekst).
3. forlop.py på begge eksempelfilene mot fasit/ (tekst; trenger numpy og scipy).
4. Full kjøring av sim.py i en midlertidig mappe: datafilene mot fasit/data/ byte for byte,
med største absolutte avvik per kolonne når en fil er ulik, og utskriften mot
fasit/sim_utskrift.txt. Om lag 1-2 min; --rask hopper over den.
I tillegg kjøres grensetilfelle() i hver utvidelse i utvidelser/; hvert avvik skal være eksakt 0.
Nivået til slutt:
- «bit for bit som notatet»: alt er likt byte for byte.
- «innenfor toleranse»: teksten er lik, og hvert tall avviker med høyst én enhet i siste
viste siffer (det som kan skje med andre versjoner av numpy og scipy, eller en annen maskin).
- «endret»: alt annet. I en arbeidskopi (mappa over modell/ har LES-MEG.md, som
arbeidskopi.py lager) er det en opplysning, ikke en feil: detaljene sier hvilke av
notatets resultater som har flyttet seg, og hvor mye.
Exit-kode: 0 for bit for bit og innenfor toleranse, 1 for endret, 3 når numpy eller scipy
mangler for noe av kontrollen (det som kunne kjøres, er kjørt), 2 for feil bruk.
Grensesnitt: main(argv: list[str]) -> int, som kjor.py kaller.
Utvidelser: utvidelser/<navn>.py (ikke filer som begynner med _) med
grensetilfelle() -> list av (beskrivelse, største absolutte avvik mot kjernen). Hver
kjøres i en egen prosess med modell/kjerne og modell/ først i sys.path.
Bare standardbiblioteket her; kjernen kjøres som egne prosesser med samme Python.
"""
from __future__ import annotations
import difflib
import hashlib
import importlib
import json
import math
import os
import re
import subprocess
import sys
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
MODELL = Path(__file__).resolve().parent
KJERNE = MODELL / "kjerne"
ROT = MODELL.parent
FASIT = ROT / "fasit"
EKSEMPLER = ROT / "eksempler"
EKSEMPELFILER = ("eksempel_domstol", "norge_2025")
ARBEIDSKOPI_MERKE = "LES-MEG.md"
LIK, TOLERANSE, ENDRET, IKKE_KJORT = "lik", "toleranse", "endret", "ikke kjørt"
RANG = {LIK: 0, TOLERANSE: 1, IKKE_KJORT: 2, ENDRET: 3}
MAKS_LINJER = 40 # linjer med forskjeller som vises per sammenligning
SLINGRING = 1e-9 # relativ slingring når avviket sammenlignes med én enhet i siste siffer
TALL = re.compile(r"[-+]?\d+(?:[.,]\d+)?(?:[eE][-+]?\d+)?")
MILJO = dict(os.environ, PYTHONIOENCODING="utf-8", PYTHONDONTWRITEBYTECODE="1")
UTVIDELSE_KJORER = """\
import importlib.util, json, sys
modell, fil = sys.argv[1], sys.argv[2]
sys.path[:0] = [modell + "/kjerne", modell]
spec = importlib.util.spec_from_file_location("utvidelse", fil)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
print()
print(json.dumps([[str(b), float(a)] for b, a in modul.grensetilfelle()]))
"""
@dataclass
class Dom:
"""Resultatet av én kontroll."""
status: str
linje: str
detaljer: list[str] = field(default_factory=list)
avvik: float = 0.0
@dataclass
class Sammenligning:
"""Resultatet av å sammenligne en fil eller en utskrift med fasit."""
status: str
avvik: float = 0.0
detaljer: list[str] = field(default_factory=list)
# ---------------- sammenligning ----------------
def enhet(tall: str) -> float:
"""Én enhet i siste viste siffer: «1,28» -> 0,01, «7.21» -> 0,01, «1.11e-15» -> 1e-17."""
mantisse, _, eksponent = tall.lower().partition("e")
desimaler = len(mantisse.replace(",", ".").partition(".")[2])
return float(10.0 ** (int(eksponent or "0") - desimaler))
def verdi(tall: str) -> float:
"""Tallet i en tekst, med punktum eller komma som desimaltegn."""
return float(tall.replace(",", "."))
def innenfor(fasit: str, naa: str) -> tuple[bool, float]:
"""Er to tall like innenfor én enhet i siste viste siffer? Returnerer også avviket."""
try:
a, b = verdi(fasit), verdi(naa)
except ValueError:
return fasit == naa, 0.0 if fasit == naa else math.inf
avvik = abs(a - b) if math.isfinite(a) and math.isfinite(b) else (0.0 if a == b else math.inf)
return avvik <= max(enhet(fasit), enhet(naa)) * (1 + SLINGRING), avvik
def vis_tall(x: float) -> str:
"""Et avvik med norsk desimaltegn."""
if x == 0:
return "0"
if math.isinf(x):
return "uendelig"
return f"{x:.1e}".replace(".", ",")
def forskjeller(fasit: list[str], naa: list[str]) -> list[str]:
"""Linjene som er ulike, som en kort diff (notatet / nå)."""
linjer = list(difflib.unified_diff(fasit, naa, "notatet", "nå", lineterm="", n=0))
if len(linjer) > MAKS_LINJER:
linjer = [*linjer[:MAKS_LINJER], f"... ({len(linjer) - MAKS_LINJER} linjer til)"]
return linjer
def sammenlign_tekst(fasit: str, naa: str) -> Sammenligning:
"""Sammenlign en utskrift med fasit: lik, innenfor toleranse eller endret."""
if fasit == naa:
return Sammenligning(LIK)
a, b = fasit.splitlines(), naa.splitlines()
detaljer = forskjeller(a, b)
if len(a) != len(b):
return Sammenligning(ENDRET, math.inf, detaljer)
storst = 0.0
for linje_a, linje_b in zip(a, b):
if linje_a == linje_b:
continue
if TALL.sub("#", linje_a) != TALL.sub("#", linje_b):
return Sammenligning(ENDRET, math.inf, detaljer)
for x, y in zip(TALL.findall(linje_a), TALL.findall(linje_b)):
ok, avvik = innenfor(x, y)
if not ok:
return Sammenligning(ENDRET, avvik, detaljer)
storst = max(storst, avvik)
return Sammenligning(TOLERANSE, storst, detaljer)
def les_dat(tekst: str) -> tuple[list[str], list[list[str]]]:
"""Kolonnenavn og rader i en datafil fra sim.py (første linje er kolonnenavnene)."""
linjer = [linje.split() for linje in tekst.splitlines() if linje.strip()]
if not linjer:
return [], []
return linjer[0], linjer[1:]
def sammenlign_dat(fasit: str, naa: str) -> Sammenligning:
"""Sammenlign en datafil med fasit; ved avvik største absolutte avvik per kolonne."""
if fasit == naa:
return Sammenligning(LIK)
kol_a, rader_a = les_dat(fasit)
kol_b, rader_b = les_dat(naa)
if kol_a != kol_b:
return Sammenligning(ENDRET, math.inf,
[f"kolonnene er ulike: notatet {' '.join(kol_a)}, nå {' '.join(kol_b)}"])
if len(rader_a) != len(rader_b) or any(len(x) != len(y) for x, y in zip(rader_a, rader_b)):
return Sammenligning(ENDRET, math.inf,
[f"antall rader er ulikt: notatet {len(rader_a)}, nå {len(rader_b)}"])
per_kolonne = [0.0] * len(kol_a)
alle_innenfor = True
for rad_a, rad_b in zip(rader_a, rader_b):
for j, (x, y) in enumerate(zip(rad_a, rad_b)):
if x == y:
continue
ok, avvik = innenfor(x, y)
alle_innenfor = alle_innenfor and ok
per_kolonne[j] = max(per_kolonne[j], avvik)
deler = [f"{navn} {vis_tall(avvik)}" for navn, avvik in zip(kol_a, per_kolonne) if avvik > 0]
detaljer = ["største absolutte avvik per kolonne: " + (", ".join(deler) or "ingen (bare formatering)")]
return Sammenligning(TOLERANSE if alle_innenfor else ENDRET, max(per_kolonne), detaljer)
def verst(statuser: list[str]) -> str:
"""Den dårligste statusen i en liste."""
return max(statuser, key=RANG.__getitem__, default=LIK)
# ---------------- miljø og prosesser ----------------
def har_numpy_scipy() -> bool:
"""Sjekk om denne Python har numpy 2.0 eller nyere (numpy.trapezoid) og scipy."""
try:
numpy = importlib.import_module("numpy")
importlib.import_module("scipy.optimize")
except ImportError:
return False
return hasattr(numpy, "trapezoid")
def kjor_python(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
"""Kjør denne Python med args i mappa cwd og fang utskriften."""
return subprocess.run([sys.executable, *args], cwd=str(cwd), env=MILJO, capture_output=True, text=True,
encoding="utf-8", check=False)
def feilmelding(prosess: subprocess.CompletedProcess[str]) -> list[str]:
"""De siste linjene på stderr, til detaljene."""
linjer = prosess.stderr.strip().splitlines()
return [f"stoppet med exit-kode {prosess.returncode}:", *linjer[-MAKS_LINJER // 2:]]
def sha256(fil: Path) -> str:
"""sha256 av en fil."""
return hashlib.sha256(fil.read_bytes()).hexdigest()
# ---------------- kontrollene ----------------
def kontroll_kjerne() -> Dom:
"""1. Kjernen mot sha256 i KILDE.json."""
kildefil = KJERNE / "KILDE.json"
if not kildefil.is_file():
return Dom(ENDRET, f"Kjernen: fant ikke {kildefil}, så kjernen kan ikke sammenlignes med notatet.")
kilde = json.loads(kildefil.read_text(encoding="utf-8"))
endret = []
for navn, info in kilde["filer"].items():
fil = KJERNE / navn
if not fil.is_file():
endret.append(f"{navn} mangler")
elif sha256(fil) != info["sha256"]:
endret.append(f"{navn} er endret")
versjon = f"{kilde['repo']} @ {kilde['commit'][:7]}"
if not endret:
return Dom(LIK, f"Kjernen: uendret fra notatet ({len(kilde['filer'])} filer, {versjon}).")
return Dom(ENDRET, f"Kjernen: endret fra notatet ({versjon}): {', '.join(endret)}.")
def kontroll_utskrift(skript: str, mappe: Path) -> Dom:
"""2 og 3. Utskriften fra diagnose.py eller forlop.py på begge eksempelfilene mot fasit."""
statuser, detaljer, storst = [], [], 0.0
for eksempel in EKSEMPELFILER:
fasitfil = FASIT / f"{skript}_{eksempel}.txt"
inndata = EKSEMPLER / f"{eksempel}.json"
mangler = [str(f) for f in (fasitfil, inndata) if not f.is_file()]
if mangler:
statuser.append(ENDRET)
detaljer.append(f"{eksempel}: fant ikke {', '.join(mangler)}")
continue
prosess = kjor_python([str(KJERNE / f"{skript}.py"), str(inndata)], mappe)
if prosess.returncode != 0:
statuser.append(ENDRET)
detaljer += [f"{eksempel}:", *(" " + linje for linje in feilmelding(prosess))]
continue
resultat = sammenlign_tekst(fasitfil.read_text(encoding="utf-8"), prosess.stdout)
statuser.append(resultat.status)
storst = max(storst, resultat.avvik)
if resultat.status != LIK:
detaljer += [f"{eksempel}:", *(" " + linje for linje in resultat.detaljer)]
status = verst(statuser)
begge = " og ".join(EKSEMPELFILER)
linje = {
LIK: f"{skript}: lik notatet for {begge}.",
TOLERANSE: f"{skript}: innenfor toleranse for {begge} (største avvik {vis_tall(storst)}).",
ENDRET: f"{skript}: endret (se under).",
}[status]
return Dom(status, linje, detaljer, storst)
def kontroll_full(mappe: Path) -> Dom:
"""4. Full kjøring av sim.py i en midlertidig mappe mot fasit/data og fasit/sim_utskrift.txt."""
print(" (kjører sim.py, om lag 1-2 minutter ...)", flush=True)
prosess = kjor_python([str(KJERNE / "sim.py")], mappe)
if prosess.returncode != 0:
return Dom(ENDRET, "Full kjøring: sim.py stoppet med en feil.", feilmelding(prosess), math.inf)
fasit_data = FASIT / "data"
navn_fasit = sorted(f.name for f in fasit_data.glob("*.dat"))
navn_naa = sorted(f.name for f in (mappe / "data").glob("*.dat"))
statuser, detaljer, storst, like = [], [], 0.0, 0
for navn in sorted(set(navn_fasit) | set(navn_naa)):
if navn not in navn_naa or navn not in navn_fasit:
statuser.append(ENDRET)
detaljer.append(f"{navn}: " + ("mangler i kjøringen" if navn not in navn_naa else "finnes ikke i fasit"))
continue
resultat = sammenlign_dat((fasit_data / navn).read_text(encoding="utf-8"),
(mappe / "data" / navn).read_text(encoding="utf-8"))
statuser.append(resultat.status)
storst = max(storst, resultat.avvik)
if resultat.status == LIK:
like += 1
else:
dom = "innenfor toleranse" if resultat.status == TOLERANSE else "endret"
detaljer.append(f"{navn} ({dom}): {'; '.join(resultat.detaljer)}")
utskriftfil = FASIT / "sim_utskrift.txt"
if utskriftfil.is_file():
utskrift = sammenlign_tekst(utskriftfil.read_text(encoding="utf-8"), prosess.stdout)
else:
utskrift = Sammenligning(ENDRET, math.inf, [f"fant ikke {utskriftfil}"])
statuser.append(utskrift.status)
storst = max(storst, utskrift.avvik)
if utskrift.status != LIK:
detaljer += ["utskriften fra sim.py:", *(" " + linje for linje in utskrift.detaljer)]
status = verst(statuser)
tekst_utskrift = {LIK: "lik", TOLERANSE: "innenfor toleranse", ENDRET: "endret"}[utskrift.status]
linje = (f"Full kjøring: {like} av {len(navn_fasit)} datafiler like byte for byte med notatet; "
f"utskriften {tekst_utskrift}.")
return Dom(status, linje, detaljer, storst)
def kontroll_utvidelser(mappe: Path, *, regnemiljo: bool) -> Dom:
"""Grensetilfellet i hver utvidelse skal gjenskape kjernen eksakt (avvik 0)."""
utvidelser = sorted(f for f in (MODELL / "utvidelser").glob("*.py") if not f.name.startswith("_"))
if not utvidelser:
return Dom(LIK, "Utvidelser: ingen å kontrollere.")
if not regnemiljo:
return Dom(IKKE_KJORT, f"Utvidelser: ikke kjørt ({len(utvidelser)} stk.), numpy og scipy mangler.")
statuser, detaljer, storst = [], [], 0.0
for fil in utvidelser:
prosess = kjor_python(["-c", UTVIDELSE_KJORER, str(MODELL), str(fil)], mappe)
try:
tilfeller = json.loads(prosess.stdout.strip().splitlines()[-1]) if prosess.returncode == 0 else None
except (ValueError, IndexError):
tilfeller = None
if tilfeller is None:
statuser.append(ENDRET)
detaljer += [f"{fil.stem}: grensetilfelle() kunne ikke kjøres", *(" " + linje for linje in
feilmelding(prosess))]
continue
if not tilfeller:
statuser.append(ENDRET)
detaljer.append(f"{fil.stem}: grensetilfelle() ga ingen tilfeller; en utvidelse skal ha minst ett.")
continue
for beskrivelse, avvik in tilfeller:
storst = max(storst, abs(avvik))
if avvik != 0.0:
statuser.append(ENDRET)
detaljer.append(f"{fil.stem}: {beskrivelse}: avvik {vis_tall(abs(avvik))}, skal være eksakt 0")
else:
statuser.append(LIK)
status = verst(statuser)
navn = ", ".join(f.stem for f in utvidelser)
linje = (f"Utvidelser: grensetilfellene gjenskaper kjernen eksakt ({navn})." if status == LIK
else f"Utvidelser: grensetilfellet gjenskaper ikke kjernen eksakt ({navn}; se under).")
return Dom(status, linje, detaljer, storst)
# ---------------- rapporten ----------------
def niva(dommer: list[Dom], *, rask: bool, arbeidskopi: bool) -> list[str]:
"""Linjene om nivået som er oppnådd."""
kjort = [d for d in dommer if d.status != IKKE_KJORT]
status = verst([d.status for d in kjort])
deler = (["uten full kjøring, --rask"] if rask else []) + (
["bare det som er kjørt"] if any(d.status == IKKE_KJORT for d in dommer) else [])
hva = f" ({'; '.join(deler)})" if deler else ""
if status == LIK:
linjer = [f"Nivå{hva}: bit for bit som notatet."]
elif status == TOLERANSE:
storst = max(d.avvik for d in kjort)
linjer = [(f"Nivå{hva}: innenfor toleranse (største avvik {vis_tall(storst)}): hvert tall avviker med "
"høyst én enhet i siste viste siffer.")]
else:
endret = [d.linje.split(":")[0] for d in kjort if d.status == ENDRET]
linjer = [f"Nivå{hva}: endret: {', '.join(endret)}."]
if arbeidskopi:
linjer.append("Dette er en arbeidskopi, så det er en opplysning, ikke en feil: detaljene over viser "
"hvilke av notatets resultater som har flyttet seg, og hvor mye.")
else:
linjer.append("Pluginens modell skal regne som notatet, så dette er en feil.")
if any(d.status == IKKE_KJORT for d in dommer):
linjer.append(f"Ikke kjørt fordi numpy eller scipy mangler i {sys.executable}. "
"«kjor.py miljo --opprett» installerer dem i en egen mappe.")
return linjer
def main(argv: list[str]) -> int:
"""Kjør kontrollene og skriv dommen. Returnerer exit-koden."""
if argv not in ([], ["--rask"]):
print("Bruk: kjor.py kontroll [--rask]", file=sys.stderr)
return 2
rask = argv == ["--rask"]
arbeidskopi = (ROT / ARBEIDSKOPI_MERKE).is_file()
regnemiljo = har_numpy_scipy()
print(f"Kontroll av modellen i {MODELL}" + (" (arbeidskopi)" if arbeidskopi else "") + "\n", flush=True)
dommer: list[Dom] = []
def vis(nummer: str, dom: Dom) -> None:
dommer.append(dom)
print(f"{nummer} {dom.linje}")
for linje in dom.detaljer:
print(f" {linje}")
sys.stdout.flush()
with tempfile.TemporaryDirectory(prefix="dynamisk-rettsstat-") as tmp:
mappe = Path(tmp)
vis("1.", kontroll_kjerne())
vis("2.", kontroll_utskrift("diagnose", mappe))
if regnemiljo:
vis("3.", kontroll_utskrift("forlop", mappe))
else:
vis("3.", Dom(IKKE_KJORT, "forlop: ikke kjørt, numpy og scipy mangler."))
if rask:
print("4. Full kjøring: hoppet over (--rask).")
elif regnemiljo:
vis("4.", kontroll_full(mappe))
else:
vis("4.", Dom(IKKE_KJORT, "Full kjøring: ikke kjørt, numpy og scipy mangler."))
vis("5.", kontroll_utvidelser(mappe, regnemiljo=regnemiljo))
print()
print("\n".join(niva(dommer, rask=rask, arbeidskopi=arbeidskopi)))
statuser = [d.status for d in dommer]
if ENDRET in statuser:
return 1
if IKKE_KJORT in statuser:
return 3
return 0
if __name__ == "__main__":
sys.dont_write_bytecode = True
sys.exit(main(sys.argv[1:]))

2
modell/requirements.txt Normal file
View file

@ -0,0 +1,2 @@
numpy==2.5.4
scipy==1.18.1