feat(scenario): scenario runner, extensions and working copy

scenario.py runs all five simulators of the core from a JSON scenario file,
with sweeps, and reproduces 20 of the note's data files (14 byte for byte,
6 column-wise). Extensions live outside the core and must reproduce it
exactly in a limit case; paadrag_tidsserie is the worked example.
arbeidskopi.py gives the user a copy of the model they can change.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-11 14:17:32 +01:00
commit 03151cfb3b
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
36 changed files with 3580 additions and 5 deletions

208
modell/arbeidskopi.py Normal file
View file

@ -0,0 +1,208 @@
# Copyright (c) 2026 Kjell Tore Guttormsen. MIT License, see LICENSE.
"""Arbeidskopi: kopier modellen til en mappe brukeren eier og kan endre.
Bruk:
python3 modell/kjor.py arbeidskopi <mappe>
python3 modell/arbeidskopi.py <mappe>
Kopierer modell/, eksempler/, fasit/, tests/ og docs/ (det som finnes), og
pyproject.toml og LICENSE når de finnes, og skriver LES-MEG.md i mappa. Nekter
hvis mappa finnes og ikke er tom. Kopierer ikke .venv, __pycache__, mellomlagre
eller resultater/. Bare standardbiblioteket.
"""
from __future__ import annotations
import json
import shlex
import shutil
import sys
from pathlib import Path
MODELLMAPPE = Path(__file__).resolve().parent
ROT = MODELLMAPPE.parent
MAPPER = ("modell", "eksempler", "fasit", "tests", "docs")
FILER = ("pyproject.toml", "LICENSE")
HOPP_OVER = (".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", "resultater",
"rettsstat-modell", ".DS_Store", "*.pyc", "*.pyo")
NODVENDIG = ("kjor.py", "scenario.py", "kontroll.py", "arbeidskopi.py", "requirements.txt",
"kjerne/sim.py", "kjerne/diagnose.py", "kjerne/forlop.py", "kjerne/KILDE.json",
"utvidelser/__init__.py")
DOKUMENTER = (
("scenarioformat.md", "scenariofila: alle felt, sveip og utdata"),
("utvidelser.md", "hvordan du legger til en mekanisme som utvidelse, med mal"),
("modellkart.md", "fra ligning i notatet til funksjon og parameter i koden"),
("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>"
class KopiFeil(Exception):
"""Feil i bruken eller i kildemappa; gir exit-kode 2."""
def sjekk_kilde() -> None:
"""Stopp hvis modell/ ikke er komplett."""
mangler = [n for n in NODVENDIG if not (MODELLMAPPE / n).is_file()]
if mangler:
melding = (f"Modellen i {MODELLMAPPE} er ikke komplett, så arbeidskopien ville ikke virket. Mangler: "
+ ", ".join(f"modell/{n}" for n in mangler) + ".")
raise KopiFeil(melding)
def sjekk_mal(mal: Path) -> None:
"""Mappa må være ny eller tom, og ikke ligge inne i det som kopieres."""
if mal.exists() and not mal.is_dir():
melding = f"{mal} finnes og er en fil, ikke en mappe."
raise KopiFeil(melding)
if mal.is_dir() and any(mal.iterdir()):
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)
def kildeversjon() -> str:
"""Commit i notatets repo som kjernen er kopiert fra (fra KILDE.json)."""
try:
data = json.loads((MODELLMAPPE / "kjerne" / "KILDE.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
return "ukjent"
if not isinstance(data, dict):
return "ukjent"
commit = str(data.get("commit", "ukjent"))
repo = str(data.get("repo", data.get("url", "")))
return f"{repo} @ {commit[:7]}" if repo else commit[:7]
def les_meg(mal: Path, docs: list[str]) -> str:
"""Innholdet i LES-MEG.md."""
kjor = f"python3 {shlex.quote(str(mal / 'modell' / 'kjor.py'))}"
eks = mal / "eksempler"
def q(sti: Path) -> str:
return shlex.quote(str(sti))
linjer = [
"# Arbeidskopi av modellen bak «Den dynamiske rettsstaten»",
"",
(f"Denne mappa er din egen kopi av modellen i pluginen dynamisk-rettsstat. Kjernen i `modell/kjerne/` er "
f"en uendret kopi av notatets kode ({kildeversjon()}); sjekksummene står i `modell/kjerne/KILDE.json`."),
"",
"## Hva du kan endre",
"",
"Alt i mappa kan endres, også kjernen. Tre veier, fra minst til mest inngripende:",
"",
"1. Andre forutsetninger: skriv en scenariofil (se `docs/scenarioformat.md`). Ingen kode endres.",
("2. En ny mekanisme ved siden av kjernen: legg den i `modell/utvidelser/` (se `docs/utvidelser.md`). "
"Regelen er den samme som i notatet: utvidelsen må ha et grensetilfelle som gjenskaper kjernen eksakt."),
("3. Endre kjernen selv (`modell/kjerne/sim.py`). Kontrollen under sier da hvilke av notatets resultater "
"som flytter seg, og hvor mye."),
"",
"## Slik kjører du kopien",
"",
"Kommandoene bruker koden i denne mappa:",
"",
"```",
f"{kjor} scenario {q(eks / 'scenarioer' / 'avviklingstid_koordinert.json')}",
f"{kjor} diagnose {q(eks / 'eksempel_domstol.json')}",
f"{kjor} forlop {q(eks / 'eksempel_domstol.json')}",
"```",
"",
"Fra pluginen kan du peke på kopien med `--modell`:",
"`python3 <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; "
f"`{kjor} miljo --opprett` installerer dem i en egen mappe."),
"",
"## Slik kontrollerer du en endring",
"",
"```",
f"{kjor} kontroll # alt, om lag 1-2 minutter",
f"{kjor} kontroll --rask # uten den fulle kjøringen av sim.py",
"```",
"",
"Kontrollen sammenligner med notatets resultater i `fasit/` og ender med ett av tre nivåer:",
"",
"- «bit for bit som notatet»: endringen flytter ingen av notatets resultater.",
("- «innenfor toleranse»: tallene avviker med høyst én enhet i siste viste siffer, som med andre versjoner "
"av numpy og scipy."),
("- «endret: ...»: noe har flyttet seg. I en arbeidskopi er det en opplysning, ikke en feil. Detaljene "
"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`).",
"",
"## Dokumentasjon",
"",
]
if docs:
linjer.extend(f"- `docs/{navn}`: {hva}" for navn, hva in DOKUMENTER if navn in docs)
linjer.extend(f"- `docs/{navn}`" for navn in docs if navn not in {n for n, _ in DOKUMENTER})
else:
linjer.append("- (docs/ fantes ikke da kopien ble laget)")
linjer += [
"",
"## Sende en forbedring tilbake",
"",
("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`).",
"",
]
return "\n".join(linjer)
def kopier(mal: Path) -> int:
"""Lag arbeidskopien. Returnerer antall filer."""
sjekk_kilde()
sjekk_mal(mal)
mal.mkdir(parents=True, exist_ok=True)
ignorer = shutil.ignore_patterns(*HOPP_OVER)
for navn in MAPPER:
kilde = ROT / navn
if kilde.is_dir():
shutil.copytree(kilde, mal / navn, ignore=ignorer)
for navn in FILER:
if (ROT / navn).is_file():
shutil.copy2(ROT / navn, mal / navn)
docs = sorted(p.name for p in (mal / "docs").glob("*.md")) if (mal / "docs").is_dir() else []
(mal / "LES-MEG.md").write_text(les_meg(mal, docs), encoding="utf-8")
return sum(1 for p in mal.rglob("*") if p.is_file())
def main(argv: list[str]) -> int:
"""Kommandoen arbeidskopi. Exit-kode 0 ok, 2 feil i bruken."""
if argv and argv[0] in {"-h", "--hjelp", "--help"}:
sys.stdout.write(BRUK + "\n")
return 0
if len(argv) != 1 or not argv[0].strip():
sys.stderr.write(BRUK + "\n")
return 2
mal = Path(argv[0]).expanduser().resolve()
try:
antall = kopier(mal)
except KopiFeil as e:
sys.stderr.write(f"{e}\n")
return 2
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"))
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"og kontroller endringer med:\n"
f" python3 {kjor} kontroll\n")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

1508
modell/scenario.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,8 @@
# Copyright (c) 2026 Kjell Tore Guttormsen. MIT License, see LICENSE.
"""Utvidelser: mekanismer utenfor kjernen.
Hver modul her har NAVN, BESKRIVELSE, PARAMETERE, simuler(scenario) og
grensetilfelle(). Regelen er den samme som i notatet: en utvidelse skal ha et
grensetilfelle som gjenskaper kjernen eksakt, og en test som viser det. Se
docs/utvidelser.md.
"""

View file

@ -0,0 +1,258 @@
# Copyright (c) 2026 Kjell Tore Guttormsen. MIT License, see LICENSE.
"""Utvidelse: det strukturelle pådraget følger en tidsserie.
I kjernen (sim.py, Omega) er pådraget Omega0 før måned t_shock og Omega0 * shock
etterpå: ett varig sjokk. Her følger pådraget Omega0 * f(t), der f er en
tidsserie brukeren oppgir, som trappetrinn eller stykkevis lineær. Da kan en
forbigående bølge, et sjokk i flere trinn eller en gradvis endring kjøres.
Styringen er P-regelen fra kjernen. Med kjeden (parametere.CH0.Z endelig) er
regnestegene de samme som i simulate_chain, med tiltakene i kjeden (andel
utenom, kapasitet, trommel-buffer-tau). Uten kjeden (CH0.Z = "inf") er de de
samme som i simulate.
Grensetilfelle: et trappetrinn lik notatets sjokk (faktor 1,3 fra måned 12)
gjenskaper simulate_chain("P", 3.0, CH0) og, med CH0.Z = inf, simulate("P", 3.0)
bit for bit. Regnestegene under er kopier av løkkene i sim.py; endres de der,
må de endres likt her, og grensetilfellet viser om det er glemt.
"""
from __future__ import annotations
import importlib
import math
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
import numpy as np
if TYPE_CHECKING:
from types import ModuleType
import numpy.typing as npt
Arr = npt.NDArray[np.float64]
NAVN = "paadrag_tidsserie"
BESKRIVELSE = ("det strukturelle pådraget Omega følger en tidsserie (trappetrinn eller stykkevis "
"lineær) i stedet for ett varig sjokk; P-regel, med eller uten straffesakskjeden")
PARAMETERE: dict[str, object] = {
"form": "trapp", # "trapp" eller "lineaer"
"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
FORMER = ("trapp", "lineaer")
MAKS_PUNKTER = 1000
KJERNE = Path(__file__).resolve().parents[1] / "kjerne"
def _sim() -> ModuleType:
"""Kjernen (sim.py) fra modellmappa denne utvidelsen ligger i."""
sim = sys.modules.get("sim")
if sim is not None and Path(str(getattr(sim, "__file__", ""))).resolve().parent == KJERNE:
return sim
sys.modules.pop("sim", None)
if str(KJERNE) in sys.path:
sys.path.remove(str(KJERNE))
sys.path.insert(0, str(KJERNE))
return importlib.import_module("sim")
def _punkter(raa: object) -> list[tuple[float, float]]:
"""Valider punktene: [[måned, faktor], ...] med stigende måneder."""
hvor = "utvidelse.punkter"
if not isinstance(raa, list) or not 1 <= len(raa) <= MAKS_PUNKTER:
melding = f"{hvor}: må være en liste med 1 til {MAKS_PUNKTER} punkter [måned, faktor]."
raise ValueError(melding)
ut: list[tuple[float, float]] = []
for nr, p in enumerate(raa):
ok = (isinstance(p, list) and len(p) == 2 # noqa: PLR2004 - et punkt er et par
and all(isinstance(x, (int, float)) and not isinstance(x, bool) for x in p))
if not ok:
melding = f"{hvor}[{nr}]: må være et par [måned, faktor] med tall, ikke {p!r}."
raise ValueError(melding)
tid, faktor = float(p[0]), float(p[1])
if not (math.isfinite(tid) and math.isfinite(faktor)) or tid < 0 or faktor < 0:
melding = f"{hvor}[{nr}]: måned og faktor må være tall fra og med 0."
raise ValueError(melding)
if ut and tid <= ut[-1][0]:
melding = f"{hvor}[{nr}]: månedene må stige (måned {tid:g} kommer etter {ut[-1][0]:g})."
raise ValueError(melding)
ut.append((tid, faktor))
return ut
def faktor(tid: float, form: str, punkter: list[tuple[float, float]]) -> float:
"""f(t): 1 før første punkt; trapp holder verdien til neste punkt, lineaer interpolerer."""
if tid < punkter[0][0]:
return 1.0
if form == "trapp":
verdi = 1.0
for t0, f0 in punkter:
if tid >= t0:
verdi = f0
else:
break
return verdi
for (t0, f0), (t1, f1) in zip(punkter, punkter[1:]):
if t0 <= tid < t1:
return f0 + (f1 - f0) * (tid - t0) / (t1 - t0)
return punkter[-1][1]
def sjekk(scenario: dict[str, object]) -> None:
"""Kalles av scenariokjøreren før kjøringen; ValueError gir en norsk feilmelding."""
sim = _sim()
styring = cast("dict[str, Any]", scenario["styring"])
if styring["regulator"] != "P":
melding = 'styring.regulator: utvidelsen paadrag_tidsserie bruker bare "P".'
raise ValueError(melding)
par = cast("dict[str, dict[str, Any]]", scenario["parametere"])
for nokkel in ("shock", "t_shock"):
if par["P"][nokkel] != sim.P[nokkel]:
melding = (f"parametere.P.{nokkel}: brukes ikke av paadrag_tidsserie. Pådraget følger "
"utvidelse.punkter.")
raise ValueError(melding)
if math.isinf(par["CH0"]["Z"]) and scenario.get("tiltak"):
melding = "tiltak: virker bare i kjeden, og parametere.CH0.Z er inf (ingen kjede)."
raise ValueError(melding)
u = cast("dict[str, Any]", scenario["utvidelse"])
if u["form"] not in FORMER:
melding = f'utvidelse.form: må være "trapp" eller "lineaer", ikke {u["form"]!r}.'
raise ValueError(melding)
_punkter(u["punkter"])
def _deriv(tilstand: tuple[float, float, float, float], om: float,
p: dict[str, Any]) -> tuple[float, float, float]:
"""Som deriv i sim.py, med pådraget om i stedet for Omega(t, p). Tilstand: K, U, L, T."""
sim = _sim()
krim, trygg, tillit, innsats = tilstand
d_krim = om - sim.removal(krim, tillit, innsats, p) - p["delta"] * krim
u_eq = np.clip(p["U0"] - p["psiU"] * (krim - 1.0), 0.0, 1.0)
d_trygg = p["kU"] * (u_eq - trygg)
over = max(0.0, innsats - p["Tprop"] * krim)
d_tillit = (p["kL"] * (p["Lam0"] + p["mu1"] * (trygg - p["U0"]) - tillit)
- p["mu2"] * tillit * over ** 2)
return d_krim, d_trygg, d_tillit
def _uten_kjede(t: Arr, om: Arr, styring: tuple[float, float], p: dict[str, Any]) -> dict[str, object]:
"""Løkka i simulate (P-regelen), med pådraget fra tidsserien. Styring: (tau, kp)."""
tau, kp = styring
sim = _sim()
dt = sim.DT
n = len(t)
krim = np.ones(n)
trygg = np.full(n, p["U0"])
tillit = np.full(n, p["Lam0"])
innsats = np.full(n, p["T0"])
lag = round(tau / dt)
for k in range(n - 1):
k_obs = krim[k - lag] if k - lag >= 0 else 1.0
innsats[k] = np.clip(p["T0"] + kp * (k_obs - p["Kstar"]), 0.0, p["Tmax"])
d_krim, d_trygg, d_tillit = _deriv((krim[k], trygg[k], tillit[k], innsats[k]), om[k], p)
krim[k + 1] = krim[k] + dt * d_krim
trygg[k + 1] = trygg[k] + dt * d_trygg
tillit[k + 1] = np.clip(tillit[k] + dt * d_tillit, 1e-4, 1 - 1e-4)
innsats[-1] = innsats[-2]
return {"t": t, "K": krim, "U": trygg, "L": tillit, "T": innsats}
def _med_kjede(t: Arr, om: Arr, styring: tuple[float, float], p: dict[str, Any],
ch: dict[str, Any]) -> dict[str, object]:
"""Løkka i simulate_chain (P-regelen og trommel-buffer-tau), med pådraget fra tidsserien."""
tau, kp = styring
sim = _sim()
dt = sim.DT
n = len(t)
krim = np.ones(n)
trygg = np.full(n, p["U0"])
tillit = np.full(n, p["Lam0"])
innsats = np.full(n, p["T0"])
ko, tid_d, inn_d, ut_d = np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)
q0, tau_ref = sim.init_chain(ch, p)
ko[0] = q0
lag = round(tau / dt)
for k in range(n - 1):
k_obs = krim[k - lag] if k - lag >= 0 else 1.0
innsats[k] = np.clip(p["T0"] + kp * (k_obs - p["Kstar"]), 0.0, p["Tmax"])
if ch["dbr"] is not None:
z, xi = sim.chain_at(ch, t[k])
t_tak = ch["dbr"] * z / (xi * p["gamma"] * tillit[k] ** p["eta"] * k_obs)
innsats[k] = min(innsats[k], max(t_tak, 0.0))
d_krim, d_trygg, d_tillit, d_ko, tid_d[k], inn_d[k], ut_d[k] = sim.deriv_chain(
krim[k], trygg[k], tillit[k], innsats[k], ko[k], t[k], p, ch, tau_ref, Om0=om[k])
krim[k + 1] = krim[k] + dt * d_krim
trygg[k + 1] = trygg[k] + dt * d_trygg
tillit[k + 1] = np.clip(tillit[k] + dt * d_tillit, 1e-4, 1 - 1e-4)
ko[k + 1] = max(ko[k] + dt * d_ko, 0.0)
innsats[-1] = innsats[-2]
tid_d[-1] = tid_d[-2]
inn_d[-1] = inn_d[-2]
ut_d[-1] = ut_d[-2]
par = [sim.chain_at(ch, float(x)) for x in t]
z_t = np.array([float(a) for a, _ in par])
return {"t": t, "K": krim, "U": trygg, "L": tillit, "T": innsats, "Q": ko, "tauD": tid_d,
"JD": inn_d, "RD": ut_d, "Z": z_t, "Z_rel": z_t / ch["Z"],
"xi": np.array([float(b) for _, b in par]), "tau_ref": tau_ref}
def simuler(scenario: dict[str, object]) -> dict[str, object]:
"""Kjør modellen med pådraget fra tidsserien. Nøkler som i kjernen (t, K, U, L, T, ...)."""
sim = _sim()
styring = cast("dict[str, Any]", scenario["styring"])
par = cast("dict[str, dict[str, Any]]", scenario["parametere"])
u = cast("dict[str, Any]", scenario["utvidelse"])
p, ch = par["P"], par["CH0"]
punkter = _punkter(u["punkter"])
slutt = float(cast("float", scenario["slutt"]))
n = int(slutt / sim.DT) + 1
t = np.arange(n) * sim.DT
f = np.array([faktor(x, u["form"], punkter) for x in t])
om = np.array([p["Omega0"] * x for x in f])
tau_kp = (float(styring["forsinkelse"]), float(styring["reaktivitet"]))
ut = _uten_kjede(t, om, tau_kp, p) if math.isinf(ch["Z"]) else _med_kjede(t, om, tau_kp, p, ch)
ut["Omega_faktor"] = f
return ut
def _scenario(p: dict[str, Any], ch: dict[str, Any], slutt: float) -> dict[str, object]:
return {
"styring": {"regulator": "P", "forsinkelse": 3.0, "reaktivitet": 3.0},
"slutt": slutt,
"parametere": {"P": p, "CH0": ch},
"tiltak": {},
"utvidelse": {"form": "trapp", "punkter": [[float(p["t_shock"]), float(p["shock"])]]},
}
def _avvik(egen: dict[str, object], fasit: dict[str, Any], navn: tuple[str, ...]) -> float:
"""Største absolutte avvik over tidsseriene (inf hvis lengdene er ulike)."""
storst = 0.0
for n in navn:
a, b = np.asarray(egen[n]), np.asarray(fasit[n])
if a.shape != b.shape:
return math.inf
storst = max(storst, float(np.abs(a - b).max()))
return storst
def grensetilfelle() -> list[tuple[str, float]]:
"""Trappetrinn lik notatets sjokk gjenskaper kjernen; avvikene skal være eksakt 0."""
sim = _sim()
slutt = float(sim.TEND)
egen = simuler(_scenario(dict(sim.P), dict(sim.CH0), slutt))
fasit = sim.simulate_chain("P", 3.0, sim.CH0)
d_kjede = max(_avvik(egen, fasit, ("t", "K", "U", "L", "T", "Q", "tauD", "JD", "RD")),
abs(float(cast("float", egen["tau_ref"])) - float(fasit["tau_ref"])))
egen0 = simuler(_scenario(dict(sim.P), dict(sim.CH0, Z=math.inf), slutt))
t, krim, trygg, tillit, innsats = sim.simulate("P", 3.0)
d_uten = _avvik(egen0, {"t": t, "K": krim, "U": trygg, "L": tillit, "T": innsats},
("t", "K", "U", "L", "T"))
return [
('paadrag_tidsserie: trapp lik notatets sjokk, med kjeden = simulate_chain("P", 3, CH0)',
d_kjede),
('paadrag_tidsserie: trapp lik notatets sjokk, CH0.Z = inf = simulate("P", 3)', d_uten),
]