fix: findings from the independent review

Scenario runner: stops when overridden base parameters leave the start out
of balance (new field startlikevekt: krev, kalibrer, ubalanse); stops on
non-finite results; says that models with a court queue drift slowly even
without a shock and prints the drift before the shock; queue in months of
inflow; never deletes files it did not write; stricter limits.
Check: anchored on the note's own checksums for core and reference files,
runs the scenario files too, exact comparison for the diagnosis, honest
wording when parts were skipped or an extension is broken.
Launcher: unknown field names in input are rejected with a suggestion;
unwritable folders give a message, not a traceback.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-11 15:27:14 +01:00
commit 776325c680
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
14 changed files with 1524 additions and 199 deletions

View file

@ -14,8 +14,10 @@ eller resultater/. Bare standardbiblioteket.
from __future__ import annotations
import json
import os
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
@ -35,7 +37,24 @@ DOKUMENTER = (
("inndata.md", "inndatafila til diagnose og forlop"),
("forbehold.md", "hva modellen kan og ikke kan brukes til"),
)
BRUK = "Bruk: python3 modell/kjor.py arbeidskopi <mappe>"
def paa_windows(*, windows: bool | None = None) -> bool:
"""Sann på Windows (eller når testene ber om det)."""
return os.name == "nt" if windows is None else windows
def python_navn(*, windows: bool | None = None) -> str:
"""Tolken heter python på Windows og python3 ellers."""
return "python" if paa_windows(windows=windows) else "python3"
def sitert(sti: Path | str, *, windows: bool | None = None) -> str:
"""En sti slik den må skrives på kommandolinja: cmd/PowerShell på Windows, POSIX-skall ellers."""
return subprocess.list2cmdline([str(sti)]) if paa_windows(windows=windows) else shlex.quote(str(sti))
BRUK = f"Bruk: {python_navn()} modell/kjor.py arbeidskopi <mappe>"
class KopiFeil(Exception):
@ -60,11 +79,11 @@ def sjekk_mal(mal: Path) -> None:
melding = (f"Mappa {mal} finnes og er ikke tom. Velg en ny mappe; arbeidskopi skriver aldri over noe "
"som finnes.")
raise KopiFeil(melding)
for navn in MAPPER:
kilde = (ROT / navn).resolve()
if mal == kilde or kilde in mal.parents:
melding = f"{mal} ligger inne i {kilde}, som skal kopieres. Velg en mappe utenfor."
raise KopiFeil(melding)
rot = ROT.resolve()
if mal == rot or rot in mal.parents:
melding = (f"{mal} ligger inne i {rot}, modellen som skal kopieres. Velg en mappe utenfor, for eksempel "
"i arbeidsmappa di.")
raise KopiFeil(melding)
def kildeversjon() -> str:
@ -80,13 +99,14 @@ def kildeversjon() -> str:
return f"{repo} @ {commit[:7]}" if repo else commit[:7]
def les_meg(mal: Path, docs: list[str]) -> str:
def les_meg(mal: Path, docs: list[str], *, windows: bool | None = None) -> str:
"""Innholdet i LES-MEG.md."""
kjor = f"python3 {shlex.quote(str(mal / 'modell' / 'kjor.py'))}"
py = python_navn(windows=windows)
kjor = f"{py} {sitert(mal / 'modell' / 'kjor.py', windows=windows)}"
eks = mal / "eksempler"
def q(sti: Path) -> str:
return shlex.quote(str(sti))
return sitert(sti, windows=windows)
linjer = [
"# Arbeidskopi av modellen bak «Den dynamiske rettsstaten»",
@ -115,7 +135,7 @@ def les_meg(mal: Path, docs: list[str]) -> str:
"```",
"",
"Fra pluginen kan du peke på kopien med `--modell`:",
"`python3 <plugin>/modell/kjor.py --modell " + q(mal) + " scenario <fil.json>`. Ferdighetene i "
f"`{py} <plugin>/modell/kjor.py --modell " + q(mal) + " scenario <fil.json>`. Ferdighetene i "
"pluginen bruker kopien selv når mappa du jobber i, har en `rettsstat-modell/`.",
"",
("Scenario, forløp og kontroll trenger numpy og scipy. Mangler de, sier kommandoen fra; "
@ -137,7 +157,7 @@ def les_meg(mal: Path, docs: list[str]) -> str:
"sier hvilke datafiler og utskrifter som er endret, og største avvik per kolonne."),
"",
"Kontrollen kjører også `grensetilfelle()` i hver utvidelse; hvert avvik skal være eksakt 0.",
"Testene kjøres med `python3 -m pytest` i mappa (krever pytest; de trege med `-m treg`).",
f"Testene kjøres med `{py} -m pytest` i mappa (krever pytest; de trege med `-m treg`).",
"",
"## Dokumentasjon",
"",
@ -154,7 +174,7 @@ def les_meg(mal: Path, docs: list[str]) -> str:
("Se `BIDRA.md` i pluginen. Kort: lag en patch av endringene (for eksempel med `git diff` hvis du har lagt "
"mappa i git) og send den på e-post med en forklaring og utskriften fra kontrollen."),
"",
"Tekst og kode er under lisensene i pluginen (MIT for kode og data, se `LICENSE`).",
"Alt i pluginen er under MIT-lisensen (se `LICENSE`).",
"",
]
return "\n".join(linjer)
@ -195,12 +215,12 @@ def main(argv: list[str]) -> int:
except OSError as e:
sys.stderr.write(f"Kunne ikke lage arbeidskopien i {mal}: {e}\n")
return 2
kjor = shlex.quote(str(mal / "modell" / "kjor.py"))
kjor = f"{python_navn()} {sitert(mal / 'modell' / 'kjor.py')}"
sys.stdout.write(f"Arbeidskopien er laget i {mal} ({antall} filer).\n"
f"Les {mal / 'LES-MEG.md'} først. Kjør den med:\n"
f" python3 {kjor} scenario <fil.json>\n"
f" {kjor} scenario <fil.json>\n"
f"og kontroller endringer med:\n"
f" python3 {kjor} kontroll\n")
f" {kjor} kontroll\n")
return 0

View file

@ -19,10 +19,18 @@ hver har ``main(argv: list[str]) -> int``, der argv er argumentene etter kommand
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.
Før diagnose og forlop kalles, avvises ukjente feltnavn i hvert ledd og i "styring" (exit-kode
2, med «Mente du ...?»), fordi kjernen ser bort fra felt den ikke kjenner, og en skrivefeil
ellers gir en stille standardverdi. Gyldige felt i et ledd: "navn", kjernens diagnose.TALLFELT
(lest fra kjernen i modell-mappa når kommandoen kjøres) og merknadsfeltene i ANNOTASJONSFELT.
Gyldige felt i "styring": forlop.STYRINGSFELT hvis kjernen har den, ellers STYRINGSFELT her,
pluss merknadsfeltene. Andre felt på øverste nivå (som "merknad" og "kilder") er tillatt.
"""
from __future__ import annotations
import difflib
import importlib
import importlib.util
import json
@ -68,6 +76,8 @@ Exit-koder: 0 ok, 1 kontroll fant forskjeller, 2 feil i inndata eller bruk,
"""
TREGER_REGNEMILJO = ("forlop", "scenario", "notatet")
ANNOTASJONSFELT = ("merknad", "kilde", "kilder") # fri tekst i et ledd eller i styring; leses ikke av regningen
STYRINGSFELT = ("forsinkelse", "reaktivitet") # det forlop.les_styring leser
KOMMANDOER = ("diagnose", "forlop", "scenario", "notatet", "kontroll", "miljo", "arbeidskopi")
@ -252,12 +262,23 @@ def kjor_paa_nytt(python: Path, argv: list[str]) -> int:
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())]
def python_navn(system: str = os.name) -> str:
"""Navnet brukeren skriver for Python: «python» på Windows, ellers «python3»."""
return "python" if system == "nt" else "python3"
def kommando_tekst(valg: Valg, *args: str, system: str = os.name) -> str:
"""Kommandolinjen brukeren kan skrive for å kjøre kjor.py med samme --data.
På Windows (system "nt") med «python» og Windows-sitering, ellers med «python3» og POSIX-sitering.
"""
deler = [python_navn(system), str(Path(__file__).resolve())]
if valg.data_oppgitt:
deler += ["--data", str(valg.data)]
return " ".join(shlex.quote(d) for d in [*deler, *args])
deler += list(args)
if system == "nt":
return subprocess.list2cmdline(deler)
return " ".join(shlex.quote(d) for d in deler)
def melding_mangler(valg: Valg, tolk: Tolk) -> str:
@ -358,12 +379,63 @@ def kjor_modul(valg: Valg, navn: str) -> int:
# ---------------- kommandoene ----------------
def feltmelding(hvor: str, felt: str, gyldige: tuple[str, ...]) -> str:
"""Melding om et ukjent feltnavn, med forslag når det ligner et gyldig."""
treff = difflib.get_close_matches(felt, gyldige, n=1, cutoff=0.6)
forslag = f' Mente du "{treff[0]}"?' if treff else ""
return (f'Feil i inndata, {hvor}: feltet "{felt}" finnes ikke.{forslag} '
f"Gyldige felt: {', '.join(gyldige)}.")
def ukjent_felt(fil: str, diagnose: object, forlop: object = None) -> str | None:
"""Melding om det første ukjente feltet i et ledd eller i styring, ellers None.
Fila leses bare for å finne feltnavn. Kan den ikke leses, eller har den feil form, sier
kjernen fra om det selv, med sin egen melding.
"""
try:
data = json.loads(Path(fil).read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(data, dict):
return None
tallfelt = tuple(str(f) for f in getattr(diagnose, "TALLFELT", ()))
gyldige_ledd = ("navn", *tallfelt, *ANNOTASJONSFELT)
alle_ledd = data.get("ledd")
for nr, ledd in enumerate(alle_ledd if isinstance(alle_ledd, list) else [], 1):
if not isinstance(ledd, dict):
continue
for felt in ledd:
if felt not in gyldige_ledd:
return feltmelding(f"ledd {nr} ({ledd.get('navn', 'uten navn')})", felt, gyldige_ledd)
styring = data.get("styring")
if isinstance(styring, dict):
gyldige_styring = (*(str(f) for f in getattr(forlop, "STYRINGSFELT", STYRINGSFELT)), *ANNOTASJONSFELT)
for felt in styring:
if felt not in gyldige_styring:
return feltmelding("styring", felt, gyldige_styring)
return None
def avvis_ukjente_felt(fil: str, diagnose: object, forlop: object = None) -> int | None:
"""Skriv meldingen og gi exit-kode 2 når fila har et ukjent felt; None når den er i orden."""
melding = ukjent_felt(fil, diagnose, forlop)
if melding is None:
return None
sys.stdout.flush()
print(melding, file=sys.stderr)
return FEIL_BRUK
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")
feil = avvis_ukjente_felt(valg.args[0], diagnose)
if feil is not None:
return feil
return fang(lambda: diagnose.main(valg.args[0]), "diagnose.py")
@ -373,9 +445,19 @@ def kommando_forlop(valg: Valg) -> int:
melding = 'Bruk: kjor.py forlop <inndata.json> ["navn på ledd"]'
raise Bruksfeil(melding)
forlop = last_kjerne(valg.modell, "forlop")
feil = avvis_ukjente_felt(valg.args[0], getattr(forlop, "diagnose", None), forlop)
if feil is not None:
return feil
return fang(lambda: forlop.main(*valg.args), "forlop.py")
def kan_ikke_skrive(mappe: Path, feil: OSError) -> int:
"""Norsk melding og exit-kode 2 når ut-mappa ikke kan lages eller skrives i."""
sys.stdout.flush()
print(f"Kan ikke skrive i {mappe}: {feil.strerror or feil}. Velg en annen mappe med --ut.", file=sys.stderr)
return FEIL_BRUK
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"
@ -392,21 +474,33 @@ def kommando_notatet(valg: Valg) -> int:
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)
utskrift = ut / "sim_utskrift.txt"
try:
(ut / "data").mkdir(parents=True, exist_ok=True)
prove = ut / "data" / ".skriveprove"
prove.write_text("", encoding="utf-8")
prove.unlink()
logg = utskrift.open("w", encoding="utf-8")
except OSError as feil:
return kan_ikke_skrive(ut, feil)
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(
with 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:
try:
for linje in prosess.stdout or ():
sys.stdout.write(linje)
logg.write(linje)
except OSError as feil:
prosess.kill()
return kan_ikke_skrive(ut, feil)
if prosess.returncode != 0:
print(f"sim.py stoppet med exit-kode {prosess.returncode}.", file=sys.stderr)
sys.stdout.flush()
print(f"Kjøringen av sim.py stoppet underveis (exit-kode {prosess.returncode}); se meldingene over. "
f"Datafilene i {ut / 'data'} er ikke fullstendige.", file=sys.stderr)
return FEIL_BRUK
antall = len(list((ut / "data").glob("*.dat")))
print(f"\nFerdig: {antall} datafiler i {ut / 'data'}, utskriften i {utskrift}.")
@ -581,7 +675,11 @@ def main(argv: list[str]) -> int:
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)
print(f"{feil}\nSkriv «{python_navn()} kjor.py --hjelp» for bruk.", file=sys.stderr)
return FEIL_BRUK
except OSError as feil: # exit-kode 1 er forbeholdt kontroll som finner forskjeller
sys.stdout.flush()
print(f"Kan ikke lese eller skrive {feil.filename or ''}: {feil.strerror or feil}.", file=sys.stderr)
return FEIL_BRUK

View file

@ -5,30 +5,48 @@ 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).
Kontrollene, hver med én linje dom og detaljer bare ved avvik:
1. Kjernen og fasit: sha256 for filene i kjerne/ og fasit/ mot notatets verdier, som står som
konstanter i denne fila (NOTATETS_KJERNE og NOTATETS_FASIT). Ankeret er notatet, ikke
filene i samme mappe: kjerne/KILDE.json er bare en opplysning.
2. diagnose.py på begge eksempelfilene mot fasit/ (tekst, eksakt: diagnose.py bruker bare
standardbiblioteket, så ingen toleranse).
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.
5. grensetilfelle() i hver utvidelse i utvidelser/; hvert avvik skal være eksakt 0.
6. Scenariokjøreren: scenariofilene i eksempler/scenarioer/notatet/ som gjenskaper notatets
datafiler byte for byte, kjørt gjennom scenario.py i denne modell-mappa, mot fasit/data/.
De 13 raske er med i --rask; mpc11 (MPC, treg) bare i full kjøring.
Er en fasit-fil endret i forhold til notatet, sier en sammenligning mot den ingenting om
notatet; den regnes da som endret.
Nivået til slutt:
- «bit for bit som notatet»: alt er likt byte for byte.
- «bit for bit som notatet»: alt er kjørt og likt byte for byte (med --rask: alt unntatt den
fulle kjøringen, og det står i linja).
- «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.
- Mangler numpy eller scipy, sies bare at det som ble kjørt, var uten avvik, og hva som ikke
er kontrollert.
- «endret»: alt annet. Detaljene sier hvilke av notatets resultater som har flyttet seg, og
hvor mye. I en arbeidskopi (mappa over modell/ har LES-MEG.md, som arbeidskopi.py lager) er
det en opplysning, ikke en feil; ellers avhenger det av om modellen er endret med vilje.
En utvidelse som ikke gjenskaper kjernen, er alltid en feil i utvidelsen.
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.
Når kjernen synkroniseres fra notatets repo (ny commit), må disse oppdateres sammen:
kjerne/*.py, kjerne/KILDE.json, fasit/ (lages på nytt med notatets eget miljø), og
konstantene NOTATETS_COMMIT, NOTATETS_KJERNE og NOTATETS_FASIT her. Sha256 for fasit/data/
skal være lik notatets paper/data/ ved samme commit.
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.
kjøres i en egen prosess med modell/kjerne og modell/ først i sys.path. Scenariokjøreren
kjøres på samme måte: scenario.main([fil, "--ut", mappe]) skal skrive <navn>.dat i mappa.
Bare standardbiblioteket her; kjernen kjøres som egne prosesser med samme Python.
"""
@ -52,13 +70,57 @@ KJERNE = MODELL / "kjerne"
ROT = MODELL.parent
FASIT = ROT / "fasit"
EKSEMPLER = ROT / "eksempler"
SCENARIOER = EKSEMPLER / "scenarioer" / "notatet"
EKSEMPELFILER = ("eksempel_domstol", "norge_2025")
ARBEIDSKOPI_MERKE = "LES-MEG.md"
# Notatet: https://git.fromaitochitta.com/open/den-dynamiske-rettsstaten @ 2badc25 (se synkroniseringen over).
NOTATETS_REPO = "https://git.fromaitochitta.com/open/den-dynamiske-rettsstaten"
NOTATETS_COMMIT = "2badc25e5134ee26309bd8c3bd008ac64afe55a4"
NOTATETS_KJERNE = {
"sim.py": "96857a92492f5768bc5d1d98bec30f4292b603c1273913e69901b6a1b220a01e",
"diagnose.py": "e5a20c68615836dd854c1414346c7932037eefaf1adb29a18458dbd590a3367a",
"forlop.py": "5b369e30f98783b712f76e5dc90bcea4f711603944690c4f19da01efd29c6e21",
}
NOTATETS_FASIT = {
"data/endo_des.dat": "cc200766c80dd486611b4f0a0e20e229c6f1842763b205e86760bf17458f594f",
"data/endo_desp.dat": "66794618783bd098a16ddbb427c20d46795f6805e5a14f14122f86e3f8825847",
"data/endo_e5.dat": "de137561fe952336f94008378f1bed4b72bbec91ca9613a054c47860086c5671",
"data/endo_ko.dat": "ebbf9a096d04a5a52977303b221ee6d8144fc771354c86b7d0ba85262e543378",
"data/kjede_s1.dat": "5f7597aea8a39909cfa3784f841a4e4f11cff8822721fa42a5ee90e3ffcd9ce2",
"data/kjede_s2.dat": "9dedcc6821a286eca9f05d1a908e377f11c1e542baf7a30c3658b1128f7aacf9",
"data/kjede_s3.dat": "a88c964f3a98c2eb962fa2066b3c1d1f4d092b0abfadc2b93c8cd6d747b9a68d",
"data/kjede_s4.dat": "6b7dccdcade9eead1e483d046e745a1291b2238c2027f05ee38ee248787cd407",
"data/kjede_s5.dat": "cdf936372eed8f3e1117508172f63e8c72f89294cb06caf994262652d044fe59",
"data/mpc11.dat": "2609b5bb73efb12e31adf3d4b40c6798e93d40e6bd68afb2583a565d60cb3be0",
"data/p11.dat": "526a839a393c2020fff0280a39c263e0ea73d7131ff86d3d9b734be919e884da",
"data/p11d.dat": "dfa920ebeafef640735ce27eb98e50f6977e20b643a9c140a1672df86d8e8bf3",
"data/p3.dat": "448def8fa662a3b68152d184a33888f7d5a0cfcdfe619064a51a47ff07d6037c",
"data/p6.dat": "fcb78c473b4927aaf1a8360a6e2652d0363eaa6b2ac2a80ef551eb577ca4390d",
"data/prio_s2.dat": "38389ac7a3d4cc2b5ad4cf54248f0a98d89bb21eb5c8f4f1eba799c6b4b5c7fc",
"data/prio_s3.dat": "b675f87eefbcd4097732a258b6e81d06b5b5d96d4bcfbc8599270cc2079baf7e",
"data/prio_s5.dat": "dd8744bd75ee27eceb9f312c4740058d13973cdcabf499fef895d2fe32c41813",
"data/sat_B.dat": "6a24ce8f69964a055deaaa9260375b8e4cb1c6c09f1a113fd20f6f96a4251847",
"data/sat_C.dat": "851d218a3a7772da65e3b19301cdf2006582358df873bab9fcaf7c3da0236fa7",
"data/stab.dat": "9a09e14290634beafa1560a31097f76d26c592e7be46b7590e1480a01b60974e",
"data/thumb.dat": "49c07f7ae6c23c9f728be27c422e76e8afa895b9bdd46c43ea2f5d5ebe7c767d",
"data/vippe.dat": "7185a3edff28dede529a230504d63d85e4821096730acd293855cd74bd005ed1",
"diagnose_eksempel_domstol.txt": "226212b0177b54a5402c2fd83d2df334184c7533b295bb3a72a277a4b99f82f0",
"diagnose_norge_2025.txt": "3764c9236ec5afde9a47ebbeacec3e0d42cde98123a7d5a6e11c3fbec56a696e",
"forlop_eksempel_domstol.txt": "b56cf2a7408d9611088eebaba787ab5d5a1dcdd3ee6116ce73dc226e1cea88c4",
"forlop_norge_2025.txt": "ec75dae235031743816ad4b2d63830fcee6050e9e1477acb68269e7e9ded456a",
"sim_utskrift.txt": "6225f9427845d915914a0bf78acf4359f6935d437f48cb94e1da48220a99eafd",
}
# Scenariofilene som gjenskaper notatets datafil med samme navn byte for byte.
SCENARIO_RASKE = ("p3", "p6", "p11", "p11d", "kjede_s1", "kjede_s2", "kjede_s3", "kjede_s4", "kjede_s5",
"endo_e5", "endo_des", "endo_desp", "endo_ko")
SCENARIO_TREGE = ("mpc11",)
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
UTVIDELSER = "Utvidelser" # merkelappen for kontroll 5
TALL = re.compile(r"[-+]?\d+(?:[.,]\d+)?(?:[eE][-+]?\d+)?")
@ -75,6 +137,32 @@ print()
print(json.dumps([[str(b), float(a)] for b, a in modul.grensetilfelle()]))
"""
SCENARIO_KJORER = """\
import contextlib, importlib.util, io, json, os, sys, traceback
modell, ut, filer = sys.argv[1], sys.argv[2], sys.argv[3:]
sys.path[:0] = [os.path.join(modell, "kjerne"), modell]
spec = importlib.util.spec_from_file_location("scenario", os.path.join(modell, "scenario.py"))
modul = importlib.util.module_from_spec(spec)
sys.modules["scenario"] = modul
spec.loader.exec_module(modul)
svar = []
for fil in filer:
navn = os.path.splitext(os.path.basename(fil))[0]
feil = io.StringIO()
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(feil):
try:
kode = modul.main([fil, "--ut", os.path.join(ut, navn)])
except SystemExit as stopp:
kode = stopp.code if isinstance(stopp.code, int) else 2
feil.write(str(stopp.code or ""))
except Exception:
kode = 2
feil.write(traceback.format_exc())
svar.append([navn, kode, feil.getvalue()[-3000:]])
print()
print(json.dumps(svar))
"""
@dataclass
class Dom:
@ -84,6 +172,7 @@ class Dom:
linje: str
detaljer: list[str] = field(default_factory=list)
avvik: float = 0.0
ikke_kjort: str = "" # hva som ikke ble kontrollert, når status er IKKE_KJORT
@dataclass
@ -226,44 +315,77 @@ def sha256(fil: Path) -> str:
return hashlib.sha256(fil.read_bytes()).hexdigest()
def lest_fasit(rel: str, endret_fasit: set[str]) -> str | None:
"""Innholdet i fasit/<rel>, eller None når fila mangler eller er endret i forhold til notatet."""
fil = FASIT / rel
if rel in endret_fasit or not fil.is_file():
return None
return fil.read_text(encoding="utf-8")
def ugyldig_fasit(rel: str) -> str:
"""Detaljen når en fasit-fil ikke kan brukes som notatets verdi."""
if not (FASIT / rel).is_file():
return f"fant ikke fasit/{rel}"
return f"fasit/{rel} er endret i forhold til notatet, så sammenligningen sier ingenting om notatet"
# ---------------- 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"))
"""1. Kjernen mot notatets sha256 (konstantene over, ikke KILDE.json)."""
endret = []
for navn, info in kilde["filer"].items():
for navn, verdi in NOTATETS_KJERNE.items():
fil = KJERNE / navn
if not fil.is_file():
endret.append(f"{navn} mangler")
elif sha256(fil) != info["sha256"]:
elif sha256(fil) != verdi:
endret.append(f"{navn} er endret")
versjon = f"{kilde['repo']} @ {kilde['commit'][:7]}"
versjon = f"{NOTATETS_REPO} @ {NOTATETS_COMMIT[:7]}"
if not endret:
return Dom(LIK, f"Kjernen: uendret fra notatet ({len(kilde['filer'])} filer, {versjon}).")
return Dom(LIK, f"Kjernen: uendret fra notatet ({len(NOTATETS_KJERNE)} 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."""
def endrede_fasitfiler() -> set[str]:
"""Filene i fasit/ som mangler eller ikke har notatets sha256."""
return {rel for rel, verdi in NOTATETS_FASIT.items()
if not (FASIT / rel).is_file() or sha256(FASIT / rel) != verdi}
def kontroll_fasit(endret_fasit: set[str]) -> Dom:
"""1. Fasit mot notatets sha256."""
if not endret_fasit:
return Dom(LIK, f"Fasit: uendret fra notatet ({len(NOTATETS_FASIT)} filer).")
detaljer = [f"fasit/{rel}: " + ("mangler" if not (FASIT / rel).is_file() else "har ikke notatets sha256")
for rel in sorted(endret_fasit)]
return Dom(ENDRET, f"Fasit: fasit er endret i forhold til notatet ({len(endret_fasit)} av "
f"{len(NOTATETS_FASIT)} filer).", detaljer, math.inf)
def kontroll_utskrift(skript: str, mappe: Path, endret_fasit: set[str], *, toleranse: bool) -> Dom:
"""2 og 3. Utskriften fra diagnose.py eller forlop.py på begge eksempelfilene mot fasit.
toleranse=False: bare byte-lik tekst godtas (diagnose.py avhenger ikke av numpy og scipy).
"""
statuser, detaljer, storst = [], [], 0.0
for eksempel in EKSEMPELFILER:
fasitfil = FASIT / f"{skript}_{eksempel}.txt"
rel = f"{skript}_{eksempel}.txt"
fasit = lest_fasit(rel, endret_fasit)
inndata = EKSEMPLER / f"{eksempel}.json"
mangler = [str(f) for f in (fasitfil, inndata) if not f.is_file()]
if mangler:
if fasit is None or not inndata.is_file():
statuser.append(ENDRET)
detaljer.append(f"{eksempel}: fant ikke {', '.join(mangler)}")
detaljer.append(f"{eksempel}: " + (ugyldig_fasit(rel) if fasit is None else f"fant ikke {inndata}"))
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)
resultat = sammenlign_tekst(fasit, prosess.stdout)
if resultat.status == TOLERANSE and not toleranse:
resultat.status = ENDRET
statuser.append(resultat.status)
storst = max(storst, resultat.avvik)
if resultat.status != LIK:
@ -278,23 +400,27 @@ def kontroll_utskrift(skript: str, mappe: Path) -> Dom:
return Dom(status, linje, detaljer, storst)
def kontroll_full(mappe: Path) -> Dom:
def kontroll_full(mappe: Path, endret_fasit: set[str]) -> 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_fasit = sorted(rel[len("data/"):] for rel in NOTATETS_FASIT if rel.startswith("data/"))
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"))
detaljer.append(f"{navn}: " + ("mangler i kjøringen" if navn not in navn_naa
else "er ikke en av notatets datafiler"))
continue
resultat = sammenlign_dat((fasit_data / navn).read_text(encoding="utf-8"),
(mappe / "data" / navn).read_text(encoding="utf-8"))
fasit = lest_fasit(f"data/{navn}", endret_fasit)
if fasit is None:
statuser.append(ENDRET)
detaljer.append(f"{navn}: {ugyldig_fasit(f'data/{navn}')}")
continue
resultat = sammenlign_dat(fasit, (mappe / "data" / navn).read_text(encoding="utf-8"))
statuser.append(resultat.status)
storst = max(storst, resultat.avvik)
if resultat.status == LIK:
@ -302,11 +428,11 @@ def kontroll_full(mappe: Path) -> Dom:
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)
fasit_utskrift = lest_fasit("sim_utskrift.txt", endret_fasit)
if fasit_utskrift is None:
utskrift = Sammenligning(ENDRET, math.inf, [ugyldig_fasit("sim_utskrift.txt")])
else:
utskrift = Sammenligning(ENDRET, math.inf, [f"fant ikke {utskriftfil}"])
utskrift = sammenlign_tekst(fasit_utskrift, prosess.stdout)
statuser.append(utskrift.status)
storst = max(storst, utskrift.avvik)
if utskrift.status != LIK:
@ -318,40 +444,112 @@ def kontroll_full(mappe: Path) -> Dom:
return Dom(status, linje, detaljer, storst)
def grensetilfeller(fil: Path, mappe: Path) -> tuple[list[str], list[str], float]:
"""Kjør grensetilfelle() i én utvidelse. Returnerer statuser, detaljer og største avvik."""
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:
return [ENDRET], [f"{fil.stem}: grensetilfelle() kunne ikke kjøres",
*(" " + linje for linje in feilmelding(prosess))], math.inf
if not tilfeller:
return [ENDRET], [f"{fil.stem}: grensetilfelle() ga ingen tilfeller; en utvidelse skal ha minst ett."], 0.0
statuser, detaljer, storst = [], [], 0.0
for beskrivelse, avvik in tilfeller:
storst = max(storst, abs(avvik))
if avvik == 0.0:
statuser.append(LIK)
continue
statuser.append(ENDRET)
hvem = beskrivelse if str(beskrivelse).startswith(fil.stem) else f"{fil.stem}: {beskrivelse}"
detaljer.append(f"{hvem}: avvik {vis_tall(abs(avvik))}, skal være eksakt 0")
return statuser, detaljer, storst
def kontroll_utvidelser(mappe: Path, *, regnemiljo: bool) -> Dom:
"""Grensetilfellet i hver utvidelse skal gjenskape kjernen eksakt (avvik 0)."""
"""5. 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.")
return Dom(LIK, f"{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
return Dom(IKKE_KJORT, f"{UTVIDELSER}: ikke kjørt ({len(utvidelser)} stk.), numpy og scipy mangler.",
ikke_kjort="utvidelsenes grensetilfeller")
statuser, detaljer, storst, feilet = [], [], 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)
egne, egne_detaljer, avvik = grensetilfeller(fil, mappe)
statuser += egne
detaljer += egne_detaljer
storst = max(storst, avvik)
if ENDRET in egne:
feilet.append(fil.stem)
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).")
if not feilet:
return Dom(LIK, f"{UTVIDELSER}: grensetilfellene gjenskaper kjernen eksakt ({navn}).", [], storst)
linje = (f"{UTVIDELSER}: {', '.join(feilet)} gjenskaper ikke kjernen i grensetilfellet. Det er en feil i "
"utvidelsen, og resultatene den gir, kan ikke brukes (se under).")
return Dom(ENDRET, linje, detaljer, storst)
def kjor_scenarioer(navn: tuple[str, ...], mappe: Path) -> tuple[dict[str, tuple[int, str]], list[str]]:
"""Kjør scenariofilene gjennom scenario.py i en egen prosess.
Returnerer {navn: (exit-kode, stderr)} og feillinjer når prosessen selv stoppet.
"""
ut = mappe / "scenarioer"
filer = [str(SCENARIOER / f"{n}.json") for n in navn]
prosess = kjor_python(["-c", SCENARIO_KJORER, str(MODELL), str(ut), *filer], mappe)
try:
svar = json.loads(prosess.stdout.strip().splitlines()[-1]) if prosess.returncode == 0 else None
except (ValueError, IndexError):
svar = None
if svar is None:
return {}, ["scenario.py kunne ikke lastes:", *(" " + linje for linje in feilmelding(prosess))]
return {str(n): (int(kode), str(feil)) for n, kode, feil in svar}, []
def kontroll_scenario(mappe: Path, endret_fasit: set[str], *, rask: bool, regnemiljo: bool) -> Dom:
"""6. Scenariokjøreren gjenskaper notatets datafiler fra scenariofilene i eksempler/scenarioer/notatet/."""
navn = SCENARIO_RASKE if rask else SCENARIO_RASKE + SCENARIO_TREGE
if not (MODELL / "scenario.py").is_file():
return Dom(ENDRET, f"Scenariokjøreren: fant ikke {MODELL / 'scenario.py'}, så den er ikke kontrollert.",
[], math.inf)
mangler = [n for n in navn if not (SCENARIOER / f"{n}.json").is_file()]
if mangler:
return Dom(ENDRET, f"Scenariokjøreren: fant ikke {len(mangler)} av {len(navn)} scenariofiler i "
f"{SCENARIOER}, så den er ikke kontrollert.", [f"mangler: {', '.join(mangler)}"], math.inf)
if not regnemiljo:
return Dom(IKKE_KJORT, "Scenariokjøreren: ikke kjørt, numpy og scipy mangler.",
ikke_kjort="scenariokjøreren")
resultater, feil = kjor_scenarioer(navn, mappe)
if feil:
return Dom(ENDRET, "Scenariokjøreren: scenario.py stoppet før noen fil var kjørt.", feil, math.inf)
statuser, detaljer, storst, like = [], [], 0.0, 0
for n in navn:
kode, melding = resultater.get(n, (2, "ble ikke kjørt"))
fasit = lest_fasit(f"data/{n}.dat", endret_fasit)
laget = mappe / "scenarioer" / n / f"{n}.dat"
if kode != 0 or not laget.is_file():
statuser.append(ENDRET)
detaljer += [f"{n}.json: stoppet med exit-kode {kode}" if kode != 0 else f"{n}.json: laget ikke {n}.dat",
*(" " + linje for linje in melding.strip().splitlines()[-MAKS_LINJER // 2:])]
continue
if fasit is None:
statuser.append(ENDRET)
detaljer.append(f"{n}.dat: {ugyldig_fasit(f'data/{n}.dat')}")
continue
resultat = sammenlign_dat(fasit, laget.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"{n}.dat ({dom}): {'; '.join(resultat.detaljer)}")
status = verst(statuser)
linje = (f"Scenariokjøreren: {like} av {len(navn)} av notatets datafiler gjenskapt byte for byte fra "
"eksempler/scenarioer/notatet/" + (" (mpc11 bare i full kjøring)." if rask else "."))
return Dom(status, linje, detaljer, storst)
@ -360,25 +558,35 @@ def kontroll_utvidelser(mappe: Path, *, regnemiljo: bool) -> Dom:
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]
hoppet_over = [d.ikke_kjort 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."]
hva = " (uten full kjøring, --rask)" if rask else ""
if status == ENDRET:
endret = [d.linje.split(":")[0] for d in kjort if d.status == ENDRET]
linjer = [f"Nivå{hva}: endret: {', '.join(endret)}."]
if UTVIDELSER in endret:
linjer.append("En utvidelse som ikke gjenskaper kjernen i grensetilfellet, har en feil. Resultatene den "
"gir, kan ikke brukes før den er rettet.")
if [e for e in endret if e != UTVIDELSER]:
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("Er dette pluginen slik den er installert, er det en feil: pluginens modell skal regne "
"som notatet. Er modellen endret med vilje (for eksempel i en klone av repoet), er det "
"en opplysning: detaljene over viser hvilke av notatets resultater som har flyttet "
"seg, og hvor mye.")
elif hoppet_over:
kjorte = [d.linje.split(":")[0] for d in kjort]
linjer = [(f"Nivå{hva}: ingen avvik i det som ble kjørt ({', '.join(kjorte)}). Ikke kontrollert: "
f"{', '.join(hoppet_over)}.")]
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 = [f"Nivå{hva}: bit for bit som notatet."]
if hoppet_over:
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
@ -402,21 +610,25 @@ def main(argv: list[str]) -> int:
print(f" {linje}")
sys.stdout.flush()
endret_fasit = endrede_fasitfiler()
with tempfile.TemporaryDirectory(prefix="dynamisk-rettsstat-") as tmp:
mappe = Path(tmp)
vis("1.", kontroll_kjerne())
vis("2.", kontroll_utskrift("diagnose", mappe))
vis("1.", kontroll_fasit(endret_fasit))
vis("2.", kontroll_utskrift("diagnose", mappe, endret_fasit, toleranse=False))
if regnemiljo:
vis("3.", kontroll_utskrift("forlop", mappe))
vis("3.", kontroll_utskrift("forlop", mappe, endret_fasit, toleranse=True))
else:
vis("3.", Dom(IKKE_KJORT, "forlop: ikke kjørt, numpy og scipy mangler."))
vis("3.", Dom(IKKE_KJORT, "forlop: ikke kjørt, numpy og scipy mangler.", ikke_kjort="forlop"))
if rask:
print("4. Full kjøring: hoppet over (--rask).")
elif regnemiljo:
vis("4.", kontroll_full(mappe))
vis("4.", kontroll_full(mappe, endret_fasit))
else:
vis("4.", Dom(IKKE_KJORT, "Full kjøring: ikke kjørt, numpy og scipy mangler."))
vis("4.", Dom(IKKE_KJORT, "Full kjøring: ikke kjørt, numpy og scipy mangler.",
ikke_kjort="full kjøring av sim.py"))
vis("5.", kontroll_utvidelser(mappe, regnemiljo=regnemiljo))
vis("6.", kontroll_scenario(mappe, endret_fasit, rask=rask, regnemiljo=regnemiljo))
print()
print("\n".join(niva(dommer, rask=rask, arbeidskopi=arbeidskopi)))
statuser = [d.status for d in dommer]

View file

@ -22,6 +22,7 @@ import importlib.util
import itertools
import json
import math
import os
import re
import sys
from contextlib import contextmanager
@ -87,9 +88,10 @@ SATT_AV_TILTAK = {
("CH2", "xiBfun"): "tiltak.andel_utenom",
}
TOPPFELT = ("navn", "beskrivelse", "merknad", "modell", "styring", "parametere", "regel", "sjokk",
"tiltak", "slutt", "sveip", "utdata", "utvidelse")
SVEIPBARE = ("styring", "parametere", "regel", "sjokk", "tiltak", "slutt", "utvidelse")
TOPPFELT = ("navn", "beskrivelse", "merknad", "modell", "startlikevekt", "styring", "parametere", "regel",
"sjokk", "tiltak", "slutt", "sveip", "utdata", "utvidelse")
STARTVALG = ("krev", "kalibrer", "ubalanse")
SVEIPBARE = ("styring", "parametere", "regel", "sjokk", "tiltak", "slutt", "utvidelse", "startlikevekt")
STYRINGSFELT = ("regulator", "forsinkelse", "reaktivitet", "mpc_kjenner_kjeden")
SJOKKFELT = ("faktor", "fra", "til")
TILTAKSFELT = ("andel_utenom", "kapasitet", "trommel_buffer_tau")
@ -105,6 +107,7 @@ STANDARD_TILTAK_FRA = 24.0 # T_TILTAK i sim.py
STANDARD_TILTAK_OVER = 24.0 # T_UTV i sim.py
STANDARD_SOC_SJOKK = (1.6, 12.0, 36.0) # simulate_soc: shock, t_on, t_off
MAKS_SLUTT = 2400.0
MIN_SLUTT = 1.0 # kjernen trenger minst to tidssteg; én måned er minste meningsfulle horisont
MAKS_KJORINGER = 400
RUTENETT = 2 # høyst to parametere i et sveip
SEN_FASE = 60.0 # spennet i K måles etter denne måneden (som amp(K,t>60) i sim.py)
@ -112,6 +115,11 @@ SNITT_FRA = 12.0 # summary_endo: gjennomsnitt fra måned 12
MAKS_KOLONNER_I_TABELL = 6
CSV_HVERT = 20 # standard: én rad per måned (DT = 0,05)
NOTAT_HVERT = 10 # write_csv i sim.py
LIKEVEKT_TOL = 1e-12 # største tillatte rest i dK/dt ved start (pådraget er om lag 0,12 per måned)
FLYTPARAMETERE = ("gamma", "Lam0", "T0") # gir strømmen J0 = gamma Lam0 T0 som domstolen er kalibrert for
ENDO_FAST_STROM = 0.1 # simulate_endo: observert strøm de første lagD månedene (sim.py:588), fast i kjernen
UTEN_SJOKK_MND = 12.0 # måneden driften vises for når det ikke er noe sjokk (notatets sjokkmåned)
K_OMRADE = (0.01, 12.0) # merknad utenfor: området soc_equilibria i sim.py leter i (notatets K: 0,83-9,82)
class ScenarioFeil(Exception):
@ -156,12 +164,13 @@ GRENSER: dict[tuple[str, str], Grense] = {
("ENDO", "k_xi"): IKKE_NEG, ("ENDO", "tau_xi"): POSITIV, ("ENDO", "ups_star"): POSITIV,
("ENDO", "ups_hi"): POSITIV, ("ENDO", "ups_lo"): IKKE_NEG, ("ENDO", "kZ"): IKKE_NEG,
("ENDO", "L1"): IKKE_NEG, ("ENDO", "L2"): POSITIV, ("ENDO", "tau_down"): POSITIV,
("ENDO", "T_clear"): POSITIV_INF, ("ENDO", "H_pred"): POSITIV, ("ENDO", "period"): POSITIV,
("ENDO", "T_clear"): POSITIV_INF, ("ENDO", "H_pred"): POSITIV, ("ENDO", "period"): Grense(1.0),
("ENDO", "mu3"): IKKE_NEG, ("ENDO", "Lam_star"): APEN_ANDEL, ("ENDO", "k_q"): IKKE_NEG,
("ENDO", "q_max"): IKKE_NEG, ("ENDO", "lag_survey"): IKKE_NEG,
("SOC", "eps"): IKKE_NEG, ("SOC", "Kh"): POSITIV,
}
HELTALLSGRENSER = {("CH2", "c"): (1, 50), ("SOC", "pexp"): (1, 20)}
HELE_MAANEDER = {("ENDO", "period")} # budsjettsyklusen: vedtak når t % period == 0 i hele måneder (sim.py:614)
# --------------------------------------------------------------------------- kjernen
@ -455,6 +464,9 @@ class Kjoring:
tiltak: dict[str, object]
dicts: dict[str, dict[str, object]]
utvidelse: dict[str, object] = field(default_factory=dict)
startlikevekt: str = "krev"
ubalanse: list[str] = field(default_factory=list) # hvorfor starten ikke er en likevekt
kalibrert: list[tuple[str, float, float]] = field(default_factory=list) # (sti, notatets, ny)
@dataclass
@ -545,7 +557,10 @@ def parameterverdi(navn: str, nokkel: str, standard: object, verdi: object, sti:
if isinstance(standard, int):
nedre, ovre = HELTALLSGRENSER.get((navn, nokkel), (-10**9, 10**9))
return heltall(verdi, sti, nedre, ovre)
return tallverdi(verdi, sti, GRENSER.get((navn, nokkel), Grense()))
x = tallverdi(verdi, sti, GRENSER.get((navn, nokkel), Grense()))
if (navn, nokkel) in HELE_MAANEDER and not x.is_integer():
feil(sti, f"må være et helt antall måneder (kjernen gjør vedtak bare i hele måneder), ikke {vis(verdi)}.")
return x
def les_styring(raa: object, modell: str, slutt: float) -> tuple[str, float, float, bool | None]:
@ -553,8 +568,10 @@ def les_styring(raa: object, modell: str, slutt: float) -> tuple[str, float, flo
s = objekt(raa if raa is not None else {}, "styring", STYRINGSFELT)
regulatorer = REGULATORER.get(modell, ("P", "MPC", "fast"))
regulator = tekst(s.get("regulator", "P"), "styring.regulator", regulatorer)
forsinkelse = tallverdi(s.get("forsinkelse", STANDARD_FORSINKELSE), "styring.forsinkelse",
Grense(0.0, ovre=slutt))
forsinkelse = tallverdi(s.get("forsinkelse", STANDARD_FORSINKELSE), "styring.forsinkelse", IKKE_NEG)
if forsinkelse > slutt:
feil("slutt", f"horisonten ({tall_kort(slutt)} mnd) er kortere enn forsinkelsen i styringen "
f"(styring.forsinkelse = {tall_kort(forsinkelse)} mnd). Øk slutt eller kort ned forsinkelsen.")
reaktivitet = tallverdi(s.get("reaktivitet", STANDARD_REAKTIVITET), "styring.reaktivitet",
Grense(0.0, ovre=100.0))
mpc: bool | None = None
@ -725,6 +742,166 @@ def sjekk_kombinasjoner(k: Kjoring, sim: ModuleType, utv: ModuleType | None) ->
sjekk_endo(k)
# --------------------------------------------------------------------------- startlikevekten
def tall_g(x: float) -> str:
"""Et tall med fire gjeldende sifre, norsk desimaltegn."""
return f"{x:.4g}".replace(".", ",")
def oppgitte_nokler(parametere: object) -> dict[str, set[str]]:
"""Nøklene fila setter selv under parametere, per gruppe."""
ut: dict[str, set[str]] = {}
if isinstance(parametere, dict):
for navn, d in parametere.items():
if isinstance(d, dict):
ut[str(navn)] = {str(n) for n in d}
return ut
def paadragsrest(sim: ModuleType, k: Kjoring) -> float:
"""dK/dt ved start (K = 1, Lambda = Lam0, T = T0, før sjokket), regnet med kjernens funksjoner."""
p = k.dicts["P"]
om = tallfelt(p, "Omega0")
if k.modell == "simulate_soc": # kjernen rekalibrerer Omega0 for rekrutteringsleddet (sim.py:455)
s = k.dicts["SOC"]
om = om - tallfelt(s, "eps") * float(sim.hill(1.0, s)) + tallfelt(s, "eps") * float(sim.hill(1.0, s))
fjernet = float(sim.removal(1.0, tallfelt(p, "Lam0"), tallfelt(p, "T0"), p))
return om - fjernet - tallfelt(p, "delta") * 1.0
def strom_j0(p: dict[str, object]) -> float:
"""Strømmen til domstolen ved start slik kjernen kalibrerer den (J0 i sim.py:154, uten eta)."""
return tallfelt(p, "gamma") * tallfelt(p, "Lam0") * tallfelt(p, "T0") * 1.0
class Startfunn(NamedTuple):
"""Det som gjør at starten ikke er en likevekt."""
kan_kalibreres: list[str]
kan_ikke: list[str]
rettinger: list[tuple[str, str, float, float]] # (gruppe, nøkkel, gammel, ny)
def funn_grunnmodell(sim: ModuleType, k: Kjoring, oppgitt: dict[str, set[str]], f: Startfunn) -> None:
"""Pådrag, mål, tak og proporsjonalitet i grunnmodellen."""
p = k.dicts["P"]
rest = paadragsrest(sim, k)
if abs(rest) > LIKEVEKT_TOL:
ny = tallfelt(p, "Omega0") - rest
tekst_ = (f"pådraget Omega0 ({tall_g(tallfelt(p, 'Omega0'))}) er ikke lik det politiets innsats fjerner "
f"pluss det som faller fra av seg selv ved K = 1 ({tall_g(ny)}), så kriminaliteten ville "
"drevet bort fra 1,00 også uten sjokk")
if "Omega0" in oppgitt.get("P", set()):
f.kan_ikke.append(tekst_ + " (Omega0 er satt i fila)")
else:
f.kan_kalibreres.append(tekst_)
f.rettinger.append(("P", "Omega0", tallfelt(p, "Omega0"), ny))
t0, kstar = tallfelt(p, "T0"), tallfelt(p, "Kstar")
regel_virker = k.regulator == "MPC" or (k.regulator == "P" and k.reaktivitet > 0)
if regel_virker and kstar != 1.0:
f.kan_ikke.append(f"målet Kstar er {tall_g(kstar)}, ikke 1, så styringen flytter innsatsen bort fra T0 "
"allerede ved start")
if k.regulator != "fast" and t0 > tallfelt(p, "Tmax"):
f.kan_ikke.append(f"T0 ({tall_g(t0)}) er over taket Tmax ({tall_g(tallfelt(p, 'Tmax'))})")
if t0 > tallfelt(p, "Tprop") * 1.0:
f.kan_ikke.append(f"T0 ({tall_g(t0)}) er over Tprop ({tall_g(tallfelt(p, 'Tprop'))}) ganger K, så "
"innsatsen regnes som uforholdsmessig og tilliten faller fra start")
def funn_domstol(sim: ModuleType, k: Kjoring, navn: str, oppgitt: dict[str, set[str]], f: Startfunn) -> None:
"""Domstolens kapasitet og behandlingstid mot strømmen ved start (CH0 eller CH2)."""
p, ch = k.dicts["P"], k.dicts[navn]
if tallfelt(p, "eta") != 1.0:
f.kan_ikke.append(f"eta er {tall_g(tallfelt(p, 'eta'))}, men kjernen regner strømmen til domstolen ved "
"start uten eta (init_chain og simulate_chain2 i sim.py), så køen starter ikke i balanse")
j0 = strom_j0(p)
if j0 <= 0:
f.kan_ikke.append("strømmen til domstolen er 0 ved start (gamma, Lam0 eller T0 er 0), så domstolen kan "
"ikke kalibreres")
return
satt = oppgitt.get(navn, set())
flyt_endret = any(n in oppgitt.get("P", set()) and p[n] != sim.P[n] for n in FLYTPARAMETERE)
z = tallfelt(ch, "Z")
if flyt_endret and not {"Z", "m"} <= satt:
z_ny = z if "Z" in satt else j0 / float(sim.UPS0)
f.kan_kalibreres.append(
f"domstolens kapasitet og minste behandlingstid ({navn}.Z, {navn}.m) er notatets, regnet for en "
f"strøm på {tall_g(strom_j0(sim.P))} saker per måned, men strømmen er nå {tall_g(j0)}, så "
"kapasitet og saksbehandlingstid passer ikke lenger til strømmen")
if "Z" not in satt:
f.rettinger.append((navn, "Z", z, z_ny))
if "m" not in satt:
f.rettinger.append((navn, "m", tallfelt(ch, "m") if ch["m"] != "kalibrer" else math.nan, math.nan))
z = z_ny
andel = (tallfelt(ch, "xi") if navn == "CH0"
else tallfelt(ch, "piA") * tallfelt(ch, "xiA") + (1 - tallfelt(ch, "piA")) * tallfelt(ch, "xiB"))
if andel * j0 >= z:
f.kan_ikke.append(f"domstolen får {tall_g(andel * j0)} saker per måned ved start, minst like mye som "
f"kapasiteten {navn}.Z = {tall_g(z)}, så køen kan ikke stå i ro")
def kalibrert_m(sim: ModuleType, k: Kjoring, navn: str) -> float:
"""Minste behandlingstid m som gir saksbehandlingstid TAU_REF ved start.
Som MD i sim.py:155, eller calibrate_m for flere spor.
"""
ch, p = k.dicts[navn], k.dicts["P"]
j0, z, tau = strom_j0(p), tallfelt(ch, "Z"), float(sim.TAU_REF)
if navn == "CH2" and ch["c"] != 1:
return float(sim.calibrate_m(dict(ch, m=None), p))
return tau - j0 / (z * (1.0 / tau + tallfelt(ch, "varsigma")))
def startmelding(f: Startfunn, kan_kalibrere: bool) -> str: # noqa: FBT001 - intern hjelper
"""Melding når starten ikke er en likevekt og fila ikke har valgt hva som skal skje."""
grunner = "; ".join([*f.kan_kalibreres, *f.kan_ikke])
ut = (f"startpunktet er ikke en likevekt: {grunner}. Tallene ville da vist en drift som ikke kommer av "
"sjokket eller tiltakene. To veier: ")
if kan_kalibrere:
ut += ('"startlikevekt": "kalibrer" regner referansetilstanden om (' + ", ".join(
f"{g}.{n}" for g, n, _, _ in f.rettinger) + ") slik at startpunktet blir notatets referansetilstand; ")
else:
ut += '"startlikevekt": "kalibrer" kan ikke rette dette (verdiene er satt i fila eller ligger fast); '
return ut + ('"startlikevekt": "ubalanse" kjører fra ubalansen med vilje og viser i sammendraget hvor '
"langt modellen driver før sjokket. Se docs/scenarioformat.md.")
def vurder_start(sim: ModuleType, k: Kjoring, parametere: object) -> None:
"""Stopp, kalibrer eller merk kjøringen når startpunktet ikke er en likevekt ("startlikevekt")."""
oppgitt = oppgitte_nokler(parametere)
f = Startfunn([], [], [])
funn_grunnmodell(sim, k, oppgitt, f)
kjede = next((n for n in ("CH0", "CH2") if n in k.dicts), None)
if kjede is not None and not math.isinf(tallfelt(k.dicts[kjede], "Z")):
funn_domstol(sim, k, kjede, oppgitt, f)
if k.modell == "simulate_endo":
f0 = float(sim.removal(1.0, tallfelt(k.dicts["P"], "Lam0"), tallfelt(k.dicts["P"], "T0"), k.dicts["P"]))
if abs(f0 - ENDO_FAST_STROM) > LIKEVEKT_TOL:
feil("parametere.P", f"simulate_endo i kjernen bruker {tall_g(ENDO_FAST_STROM)} som observert strøm de "
f"første månedene (sim.py:588), men med disse parametrene er strømmen ved start {tall_g(f0)}. Det "
"endrer hva den koordinerte regelen gjør, og kan ikke rettes fra scenariofila. Å endre gamma, "
"Lam0, T0, eta eller ksat i simulate_endo er derfor en endring i kjernen (arbeidskopi og "
"ferdigheten forbedre).")
if not f.kan_kalibreres and not f.kan_ikke:
return
if k.startlikevekt == "krev":
feil("startlikevekt", startmelding(f, kan_kalibrere=not f.kan_ikke))
if k.startlikevekt == "ubalanse":
k.ubalanse = [*f.kan_kalibreres, *f.kan_ikke]
return
if f.kan_ikke:
feil("startlikevekt", '"kalibrer" kan ikke gjøre starten til en likevekt: ' + "; ".join(f.kan_ikke)
+ '. Endre verdiene, eller bruk "startlikevekt": "ubalanse".')
for gruppe, nokkel, gammel, ny in f.rettinger:
verdi = kalibrert_m(sim, k, gruppe) if (gruppe, nokkel) in {("CH0", "m"), ("CH2", "m")} else ny
k.dicts[gruppe][nokkel] = verdi
k.kalibrert.append((f"{gruppe}.{nokkel}", gammel, verdi))
if abs(paadragsrest(sim, k)) > LIKEVEKT_TOL:
feil("startlikevekt", "kalibreringen ga ikke en likevekt; dette er en feil i scenariokjøreren.")
def les_utvidelse(raa: object, utv: ModuleType | None) -> dict[str, object]:
"""Valider utvidelsens parametere mot PARAMETERE (samme type som standardverdien)."""
if utv is None:
@ -766,7 +943,7 @@ def los_opp(sim: ModuleType, raa: dict[str, object], modell: str, utv: ModuleTyp
variant: Variant) -> Kjoring:
"""Valider én variant av scenariofila og lag en kjøring."""
objekt(raa, "", TOPPFELT)
slutt = tallverdi(raa.get("slutt", float(sim.TEND)), "slutt", Grense(0.0, nedre_med=False, ovre=MAKS_SLUTT))
slutt = tallverdi(raa.get("slutt", float(sim.TEND)), "slutt", Grense(MIN_SLUTT, ovre=MAKS_SLUTT))
regel = les_regel(raa, modell)
regulator, forsinkelse, reaktivitet, mpc = les_styring(raa.get("styring"), modell, slutt)
dicts = les_parametere(sim, raa.get("parametere"), parameterdicts(sim, modell, utv), modell)
@ -781,6 +958,8 @@ def los_opp(sim: ModuleType, raa: dict[str, object], modell: str, utv: ModuleTyp
mpc_kjenner_kjeden=mpc, regel=regel, slutt=slutt, sjokk=sjokk,
tiltak=les_tiltak(raa.get("tiltak"), modell, regel), dicts=dicts,
utvidelse=les_utvidelse(raa.get("utvidelse"), utv))
k.startlikevekt = tekst(raa.get("startlikevekt", "krev"), "startlikevekt", STARTVALG)
vurder_start(sim, k, raa.get("parametere"))
sjekk_kombinasjoner(k, sim, utv)
return k
@ -1008,7 +1187,9 @@ def kjor(sim: ModuleType, k: Kjoring, utv: ModuleType | None) -> Resultat:
mpc_knows_chain=bool(k.mpc_kjenner_kjeden)))
avledet_kjede(sim, ch, raa)
elif k.modell == "simulate_chain2":
raa = dict(sim.simulate_chain2(tau, bygg_tiltak(k, "CH2", k.dicts["CH2"]), p=p, kp=kp))
ch2 = bygg_tiltak(k, "CH2", k.dicts["CH2"])
raa = dict(sim.simulate_chain2(tau, ch2, p=p, kp=kp))
avledet_kjede2(ch2, raa)
elif k.modell == "simulate_soc":
raa = fra_tuppel(sim.simulate_soc(k.regulator, tau, p, s=k.dicts["SOC"], kp=kp,
shock=k.sjokk["faktor"], t_on=k.sjokk["fra"],
@ -1031,6 +1212,20 @@ def avledet_kjede(sim: ModuleType, ch: dict[str, object], raa: dict[str, object]
raa["Z_rel"] = z / tallfelt(ch, "Z")
def avledet_kjede2(ch2: dict[str, object], raa: dict[str, object]) -> None:
"""Kapasitet og andelen av alle saker som går til domstolen over tid i simulate_chain2 (som court2)."""
t = raa["t"]
if not isinstance(t, np.ndarray):
return
zfun, xibfun = ch2.get("Zfun"), ch2.get("xiBfun")
z = np.array([float(zfun(float(x))) if callable(zfun) else tallfelt(ch2, "Z") for x in t])
xib = np.array([float(xibfun(float(x))) if callable(xibfun) else tallfelt(ch2, "xiB") for x in t])
pia = tallfelt(ch2, "piA")
raa["Z"] = z
raa["Z_rel"] = z / tallfelt(ch2, "Z")
raa["xi"] = pia * tallfelt(ch2, "xiA") + (1.0 - pia) * xib
def del_opp(raa: dict[str, object], k: Kjoring) -> Resultat:
"""Skill tidsserier, skalarer og logg."""
t = raa.get("t")
@ -1052,13 +1247,19 @@ def del_opp(raa: dict[str, object], k: Kjoring) -> Resultat:
def kjor_trygt(sim: ModuleType, k: Kjoring, utv: ModuleType | None) -> Resultat:
"""Som kjor, men regnefeil i kjernen blir en norsk melding (exit-kode 2)."""
hvem = f" ({kolonneoverskrift(k, 1)})" if k.sveipverdier else ""
try:
return kjor(sim, k, utv)
r = kjor(sim, k, utv)
except (ArithmeticError, ValueError) as e:
hvem = f" ({kolonneoverskrift(k, 1)})" if k.sveipverdier else ""
melding = (f"Kjøringen{hvem} stoppet i kjernen: {e}. Parameterkombinasjonen gir trolig ingen "
"likevekt ved start eller en løsning som vokser over alle grenser.")
"likevekt ved start eller en løsning som vokser over alle grenser. Ingenting er skrevet.")
raise ScenarioFeil(melding) from None
ikke_tall = [navn for navn, v in r.serier.items() if not np.all(np.isfinite(v))]
if ikke_tall:
melding = (f"Kjøringen{hvem} ga verdier som ikke er endelige tall (uendelig eller NaN) i "
f"{', '.join(ikke_tall)}: løsningen har vokst over alle grenser. Ingenting er skrevet.")
raise ScenarioFeil(melding)
return r
# --------------------------------------------------------------------------- nøkkeltall
@ -1115,16 +1316,17 @@ def tall_innsats_tillit(n: Nokkeltall, s: dict[str, Arr]) -> None:
n.legg("L_slutt", "Tillit, ved slutt", s["L"][-1], 3)
def tall_kjede(n: Nokkeltall, s: dict[str, Arr], endo: bool) -> None: # noqa: FBT001 - intern hjelper
"""Saksbehandlingstid, kø og strøm i kjeden (summary, summary2)."""
def tall_kjede(n: Nokkeltall, s: dict[str, Arr], endo: bool, j0: float) -> None: # noqa: FBT001 - intern hjelper
"""Saksbehandlingstid, kø og strøm i kjeden (summary, summary2). Køen i måneder av strømmen J0."""
for navn, tekst_ in (("tauD", "Saksbehandlingstid i domstolen"), ("tauA", "Saksbehandlingstid klasse A"),
("tauB", "Saksbehandlingstid klasse B")):
if navn in s:
n.legg(f"{navn}_maks", f"{tekst_}, lengste (mnd)", s[navn].max(), 1)
n.legg(f"{navn}_slutt", f"{tekst_}, ved slutt (mnd)", s[navn][-1], 1)
for navn in ("Q", "QA", "QB"):
for navn, hva in (("Q", "Kø i domstolen"), ("QA", "Kø klasse A"), ("QB", "Kø klasse B")):
if navn in s:
n.legg(f"{navn}_slutt", f"Kø {navn}, ved slutt", s[navn][-1], 2)
n.legg(f"{navn}_slutt_mnd", f"{hva} ved slutt (mnd innstrømning før sjokket)",
float(s[navn][-1]) / j0 if j0 > 0 else None, 1)
if "JD" in s and "RD" in s and not endo:
rd = float(s["RD"][-1])
n.legg("inn_ut_slutt", "Saker inn / avgjort, ved slutt", float(s["JD"][-1]) / rd if rd > 0 else None, 2)
@ -1146,7 +1348,7 @@ def tall_endo(n: Nokkeltall, s: dict[str, Arr], ch: dict[str, object], p: dict[s
"""Frafall og kapasitetsår (summary_endo; frafallet i måneder av innstrømningen, som i notatets tabell)."""
t = s["t"]
z0 = tallfelt(ch, "Z")
j0 = tallfelt(p, "gamma") * tallfelt(p, "Lam0") * tallfelt(p, "T0") # J0 i sim.py
j0 = strom_j0(p)
n.legg("frafall", "Frafall fra køen, i måneder av innstrømningen før sjokket",
float(np.trapezoid(tallfelt(ch, "varsigma") * s["Q"], t)) / j0 if j0 > 0 else None, 1)
n.legg("kapasitetsaar", "Kapasitetsår over utgangsnivået",
@ -1161,7 +1363,7 @@ def nokkeltall(r: Resultat, k: Kjoring) -> list[Tall]:
if "K" in s:
tall_krim(n, s, endo)
tall_innsats_tillit(n, s)
tall_kjede(n, s, endo)
tall_kjede(n, s, endo, strom_j0(k.dicts["P"]))
tall_tiltak(n, s)
if endo and "Z" in s and "Q" in s:
tall_endo(n, s, k.dicts["CH0"], k.dicts["P"])
@ -1308,6 +1510,16 @@ def hode(u: Utfall) -> list[str]:
linjer += ["", ("Parametrene er notatets stiliserte verdier (tabell 3 i notatet), ikke kalibrert på norske "
f"data, unntatt det scenariofila setter selv: {valgt}. Tallene viser modellens dynamikk, "
"ikke prognoser."), ""]
kalibrert = [(k, k.kalibrert) for k in sc.kjoringer if k.kalibrert]
if kalibrert:
linjer += [('Referansetilstanden er kalibrert på nytt ("startlikevekt": "kalibrer"): pådraget og domstolen '
"er regnet om som i notatets kalibrering, for de nye verdiene:"), ""]
for k, endr in kalibrert:
hvem = f"{kolonneoverskrift(k, len(sc.sveip))}: " if sc.sveip else ""
linjer.append("- " + hvem + "; ".join(
f"{sti} {tall_g(ny)}" + ("" if math.isnan(gammel) else f" (var {tall_g(gammel)})")
for sti, gammel, ny in endr))
linjer.append("")
return linjer
@ -1325,14 +1537,130 @@ def vedtakslinjer(u: Utfall) -> list[str]:
return linjer
def sjokkstart(k: Kjoring, r: Resultat) -> float | None:
"""Måneden sjokket (eller pådraget fra utvidelsen) begynner; None uten sjokk."""
s = r.serier
if k.modell == "simulate_soc":
return k.sjokk["fra"] if k.sjokk["faktor"] != 1.0 else None
if "Omega_faktor" in s:
endret = np.flatnonzero(s["Omega_faktor"] != 1.0)
return float(s["t"][endret[0]]) if endret.size else None
p = k.dicts["P"]
return tallfelt(p, "t_shock") if tallfelt(p, "shock") != 1.0 else None
def drift_maaned(k: Kjoring, r: Resultat) -> float:
"""Måneden driften før sjokket vises for: sjokkets start, eller måned 12 uten sjokk (høyst slutt)."""
m = sjokkstart(k, r)
return min(UTEN_SJOKK_MND if m is None else m, k.slutt)
def har_ko(r: Resultat) -> bool:
"""Sann for modeller med kø i domstolen (simulate_chain, simulate_chain2, simulate_endo, utvidelse med kjede)."""
return "Q" in r.serier or "QA" in r.serier
def driftlinje(k: Kjoring, r: Resultat, dt: float, antall_sveip: int, *, med_grunner: bool) -> str:
"""Hvor langt en kjøring har drevet fra start til sjokket (ubalanse, eller kø i domstolen)."""
s = r.serier
m = drift_maaned(k, r)
i = min(round(m / dt), len(s["t"]) - 1)
deler = [f"{hva} fra {tall(float(s[navn][0]), d)} til {tall(float(s[navn][i]), d)}"
for navn, hva, d in (("K", "K", 3), ("T", "innsatsen T", 2), ("L", "tilliten", 3),
("tauD", "saksbehandlingstiden (mnd)", 1), ("tauA", "tiden i klasse A (mnd)", 1),
("tauB", "tiden i klasse B (mnd)", 1))
if navn in s]
hvem = f"{kolonneoverskrift(k, antall_sveip)}: " if antall_sveip else ""
grunner = f"{'; '.join(k.ubalanse)}. " if med_grunner else ""
deler[0] = deler[0].replace(" fra ", " gått fra ", 1)
return f"- {hvem}{grunner}Fram til måned {tall_kort(m)} har " + ", ".join(deler) + "."
KO_DRIFT = ("I modeller med kø i domstolen er dette nesten, men ikke helt, en likevekt: det som faller fra køen, "
"blir aldri fjernet fra kriminaliteten, så K og saksbehandlingstiden kryper sakte oppover også uten sjokk.")
def ko_driftlinjer(u: Utfall, kjoringer: list[tuple[Kjoring, Resultat]]) -> list[str]:
"""Driften før sjokket i kjøringer med kø som starter i notatets referansetilstand."""
med_sjokk = [(k, r) for k, r in kjoringer if (sjokkstart(k, r) or 0.0) > 0.0]
uten = len(med_sjokk) < len(kjoringer)
linjer: list[str] = []
if med_sjokk:
linjer += ["", "Slik har modellen drevet før sjokket:", ""]
linjer += [driftlinje(k, r, u.dt, len(u.sc.sveip), med_grunner=False) for k, r in med_sjokk]
if uten:
linjer += ["", ("Uten sjokk (eller med sjokk fra måned 0) kryper modellen med kø sakte oppover av seg selv; en "
'kjøring uten sjokk ("sjokk": {"faktor": 1}) viser hvor mye.')]
return linjer
def forklaring(u: Utfall) -> list[str]:
"""Hvordan tallene leses; byttes ut når starten ikke er en likevekt."""
k0 = u.sc.kjoringer[0]
lam0 = {tallfelt(k.dicts["P"], "Lam0") for k in u.sc.kjoringer}
tillit = f"tilliten er {tall(lam0.pop(), 2)} der" if len(lam0) == 1 else "tilliten er Lam0 der"
ko = any(har_ko(r) for r in u.resultater)
resten = ("Kapasitet: 1,00 er kapasiteten ved start. Tider i måneder."
+ (f" Køen er målt i måneder av innstrømningen til domstolen før sjokket (J0 = "
f"{tall_g(strom_j0(k0.dicts['P']))} i modellens enheter); i datafilene står køen i modellens "
"enheter." if ko else ""))
alle = list(zip(u.sc.kjoringer, u.resultater))
skjeve = [(k, r) for k, r in alle if k.ubalanse]
rette_med_ko = [(k, r) for k, r in alle if not k.ubalanse and har_ko(r)]
if not skjeve and not ko:
return [(f"Slik leses tallene: 1,00 er nivået i referanselikevekten før sjokket for kriminaliteten K og "
f"politiets innsats T; {tillit}. {resten}")]
if not skjeve:
return [(f"Slik leses tallene: 1,00 er nivået ved start for kriminaliteten K og politiets innsats T "
f"(notatets referansetilstand); {tillit}. {KO_DRIFT} {resten}"), *ko_driftlinjer(u, rette_med_ko)]
like = len({tuple(k.ubalanse) for k, _ in skjeve}) == 1
linjer = [('Slik leses tallene: startpunktet er ikke en likevekt ("startlikevekt": "ubalanse")'
+ (": " + "; ".join(skjeve[0][0].ubalanse) if like else "") + ". Tallene er målt fra "
"startverdiene (K og T er 1,00 ved start), men modellen ville drevet bort fra dem også uten sjokk, "
f"så 1,00 er ikke et nivå den holder seg på. {resten} Slik har modellen drevet før sjokket:"), ""]
linjer += [driftlinje(k, r, u.dt, len(u.sc.sveip), med_grunner=not like) for k, r in skjeve]
if len(skjeve) < len(u.sc.kjoringer):
linjer += ["", ("Kjøringene som ikke er nevnt, starter i notatets referansetilstand." + (f" {KO_DRIFT}" if
rette_med_ko else ""))]
linjer += ko_driftlinjer(u, rette_med_ko) if rette_med_ko else []
return linjer
def merknader(u: Utfall) -> list[str]:
"""Merknader (ikke stopp): K langt utenfor modellens område, og sjokk utenfor horisonten."""
ut: list[str] = []
for k, r in zip(u.sc.kjoringer, u.resultater):
hvem = f" ({kolonneoverskrift(k, len(u.sc.sveip))})" if u.sc.sveip else ""
if "K" in r.serier:
kmax, kmin = float(r.serier["K"].max()), float(r.serier["K"].min())
if kmax > K_OMRADE[1] or kmin < K_OMRADE[0]:
ut.append(f"Kriminaliteten{hvem} går fra {tall(kmin, 3)} til {tall(kmax, 3)}, utenfor området "
f"kjernen selv regner på (K mellom {tall(K_OMRADE[0], 2)} og {tall(K_OMRADE[1], 0)}, der "
"soc_equilibria i sim.py leter etter likevekter; notatets egne kjøringer ligger mellom "
"0,83 og 9,82). Tallene er regnet slik modellen sier, men ligningene er ikke laget for "
"så store utslag.")
if k.modell == "simulate_soc":
if k.sjokk["fra"] >= k.slutt:
ut.append(f"Sjokket{hvem} begynner i måned {tall_kort(k.sjokk['fra'])}, ikke før slutt "
f"({tall_kort(k.slutt)}): kjøringen viser ingen virkning av det.")
elif k.sjokk["til"] > k.slutt:
ut.append(f"Sjokket{hvem} varer til måned {tall_kort(k.sjokk['til'])}, etter slutt "
f"({tall_kort(k.slutt)}): kjøringen viser ikke at det går over.")
elif k.sjokk and k.sjokk["faktor"] != 1.0 and k.sjokk["fra"] >= k.slutt:
ut.append(f"Sjokket{hvem} kommer i måned {tall_kort(k.sjokk['fra'])}, ikke før slutt "
f"({tall_kort(k.slutt)}): kjøringen viser ingen virkning av det.")
return list(dict.fromkeys(ut))
def sammendrag(u: Utfall) -> str:
"""Teksten i sammendrag.md (og på skjermen)."""
linjer = [*hode(u), "## Nøkkeltall", "", *nokkeltabeller(u.sc, u.alle)]
if len(u.sc.sveip) == RUTENETT:
linjer += matrise(u.sc, u.alle)
linjer.append("Slik leses tallene: 1,00 er nivået i referanselikevekten før sjokket for kriminaliteten K "
"og politiets innsats T; tilliten er 0,80 der. Kapasitet: 1,00 er kapasiteten ved start. "
"Tider i måneder.")
linjer += forklaring(u)
merk = merknader(u)
if merk:
linjer += ["", "## Merknader (kjøringen er ikke stoppet)", "", *(f"- {m}" for m in merk)]
linjer += vedtakslinjer(u)
linjer += ["", "## Filer", ""]
linjer.extend(f"- {navn}: {', '.join(kolonner)}" for navn, kolonner in u.filer)
@ -1378,6 +1706,11 @@ def resultatjson(sim: ModuleType, u: Utfall) -> str:
post["skalarer"] = r.skalarer
if r.logg:
post["vedtak"] = r.logg
post["startlikevekt"] = {
"valg": k.startlikevekt,
"kalibrert": {sti: {"for": gammel, "etter": ny} for sti, gammel, ny in k.kalibrert},
"ubalanse": k.ubalanse,
}
kjoringer.append(post)
forklaring: dict[str, str] = {}
for tallene in u.alle:
@ -1403,33 +1736,64 @@ def resultatjson(sim: ModuleType, u: Utfall) -> str:
return json.dumps(json_verdi(data), ensure_ascii=False, indent=2) + "\n"
EGNE_NOKLER = ("scenario", "scenariofil_sha256", "kjerne_sha256", "kjoringer") # i resultat.json
EGEN_DATAFIL = re.compile(r"[^/\\]+\.(csv|dat)")
def egne_filer(gammel: Path) -> list[str] | None:
"""Filene et tidligere resultat laget, eller None hvis resultat.json ikke er kjørerens."""
try:
data = json.loads(gammel.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(data, dict) or not all(n in data for n in EGNE_NOKLER):
return None
kjoringer = data["kjoringer"]
if not isinstance(kjoringer, list):
return None
filer = [str(x.get("fil", "")) for x in kjoringer if isinstance(x, dict)]
return [f for f in filer if EGEN_DATAFIL.fullmatch(f) and f not in {".csv", ".dat"}]
def klargjor_mappe(ut: Path) -> None:
"""Lag mappa; fjern bare filer et tidligere resultat i samme mappe har laget."""
"""Lag mappa; fjern bare datafilene, sammendraget og resultat.json et tidligere resultat laget."""
if ut.exists() and not ut.is_dir():
melding = f"--ut: {ut} finnes og er ikke en mappe."
raise ScenarioFeil(melding)
if ut.exists() and any(ut.iterdir()):
gammel = ut / "resultat.json"
if not gammel.is_file():
melding = (f"--ut: mappa {ut} finnes og har filer som ikke er et tidligere resultat. Velg en "
"annen mappe med --ut.")
filer = egne_filer(ut / "resultat.json")
if filer is None:
melding = (f"--ut: mappa {ut} finnes og har filer som ikke er et tidligere resultat fra "
"scenariokjøreren. Velg en annen mappe med --ut.")
raise ScenarioFeil(melding)
try:
data = json.loads(gammel.read_text(encoding="utf-8"))
filer = [str(x["fil"]) for x in data.get("kjoringer", [])]
except (OSError, ValueError, TypeError, KeyError, AttributeError):
filer = []
for navn in [*filer, "sammendrag.md", "resultat.json"]:
sti = ut / navn
if sti.parent == ut and sti.is_file():
if sti.parent == ut and sti.is_file() and not sti.is_symlink():
sti.unlink()
ut.mkdir(parents=True, exist_ok=True)
def skriv_alt(sim: ModuleType, sc: Scenario, fil: Path, mappe: Path,
resultater: list[Resultat]) -> str:
"""Skriv datafilene, sammendraget og resultat.json. Returnerer sammendraget."""
alle = [nokkeltall(r, k) for r, k in zip(resultater, sc.kjoringer)]
klargjor_mappe(mappe)
filer: list[tuple[str, list[str]]] = []
for k, r in zip(sc.kjoringer, resultater):
navn = filnavn(sc, k)
filer.append((navn, skriv_csv(sim, sc, r, mappe / navn)))
u = Utfall(sc=sc, fil=fil, alle=alle, resultater=resultater, filer=filer, dt=float(sim.DT))
tekst_ = sammendrag(u)
(mappe / "sammendrag.md").write_text(tekst_, encoding="utf-8")
(mappe / "resultat.json").write_text(resultatjson(sim, u), encoding="utf-8")
return tekst_
# --------------------------------------------------------------------------- inngang
BRUK = ("Bruk: python3 modell/kjor.py scenario <fil.json> [--ut <mappe>]\n"
PYTHON = "python" if os.name == "nt" else "python3" # tolken heter python på Windows
BRUK = (f"Bruk: {PYTHON} modell/kjor.py scenario <fil.json> [--ut <mappe>]\n"
"Formatet er beskrevet i docs/scenarioformat.md.")
@ -1468,18 +1832,14 @@ def kjor_scenario(fil: Path, ut: Path | None) -> tuple[str, Path]:
sim = last_kjerne()
mappe = ut if ut is not None else Path.cwd() / "resultater" / slug(sc.navn)
resultater = [kjor_trygt(sim, k, sc.utvidelse) for k in sc.kjoringer]
alle = [nokkeltall(r, k) for r, k in zip(resultater, sc.kjoringer)]
for r in resultater:
kolonner_for(sc, r)
klargjor_mappe(mappe)
filer: list[tuple[str, list[str]]] = []
for k, r in zip(sc.kjoringer, resultater):
navn = filnavn(sc, k)
filer.append((navn, skriv_csv(sim, sc, r, mappe / navn)))
u = Utfall(sc=sc, fil=fil, alle=alle, resultater=resultater, filer=filer, dt=float(sim.DT))
tekst_ = sammendrag(u)
(mappe / "sammendrag.md").write_text(tekst_, encoding="utf-8")
(mappe / "resultat.json").write_text(resultatjson(sim, u), encoding="utf-8")
try:
tekst_ = skriv_alt(sim, sc, fil, mappe, resultater)
except OSError as e:
grunn = e.strerror or str(e)
melding = f"Kan ikke skrive i {mappe}: {grunn}. Velg en annen mappe med --ut."
raise ScenarioFeil(melding) from None
return tekst_, mappe

View file

@ -42,6 +42,9 @@ PARAMETERE: dict[str, object] = {
"punkter": [[12.0, 1.3]], # [måned, faktor på Omega0]; før første punkt er faktoren 1
}
BRUKER = ("P", "CH0") # kjernens dicts som kan overstyres under parametere
# Scenariofiler i eksempler/scenarioer/notatet/ som utvidelsen med standardverdiene skal gjenskape byte for byte
# når "modell" byttes til "utvidelse:paadrag_tidsserie" (tests/test_utvidelser.py kjører dem gjennom kjøreren).
GJENSKAPER_NOTATET = ("kjede_s2", "kjede_s3", "kjede_s4", "kjede_s5")
FORMER = ("trapp", "lineaer")
MAKS_PUNKTER = 1000
KJERNE = Path(__file__).resolve().parents[1] / "kjerne"