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

229
docs/utvidelser.md Normal file
View file

@ -0,0 +1,229 @@
# Utvidelser: å legge til en mekanisme
En utvidelse er en mekanisme modellen ikke har, lagt ved siden av kjernen i stedet for inn i den. Den ligger i
`modell/utvidelser/<navn>.py`, kjøres med en scenariofil (`"modell": "utvidelse:<navn>"`) og kontrolleres av
`kontroll`. Kjernen (`modell/kjerne/`) forblir notatets kode, så det er alltid klart hva som er notatets modell og hva
som er lagt til.
## Regelen
Samme regel som i notatets kode (`sim.py`, Del 3: «Alle utvidelser har grensetilfeller som gjenskaper Del 1 og 2
eksakt»): **en utvidelse skal ha et grensetilfelle som gjenskaper kjernen eksakt, og en test som viser det.** Grensetilfellet er en verdi av utvidelsens parametere der mekanismen forsvinner,
for eksempel en faktor lik 1, et ledd lik 0 eller en kapasitet lik uendelig. Med den verdien skal utvidelsen gi
nøyaktig de samme tallene som simulatoren i kjernen den bygger på: største absolutte avvik over alle tidsserier skal
være `0.0`, ikke bare lite.
Hvorfor eksakt: da vet både du og leseren at alt utvidelsen viser utover kjernen, kommer fra mekanismen, ikke fra en
annen tallbehandling, en annen tidsstegning eller en feil i kopien av løkka. Avvik på 10⁻¹⁵ betyr at regnestegene er
gjort i en annen rekkefølge, og da er det ikke lenger kjernens modell.
## Grensesnittet
| Navn | Type | Krav |
|---|---|---|
| `NAVN` | tekst | lik filnavnet uten `.py` |
| `BESKRIVELSE` | tekst | én setning; står i sammendraget etter modellnavnet |
| `PARAMETERE` | dict | utvidelsens parametere med standardverdier. Standardverdiene bør være grensetilfellet |
| `simuler(scenario) -> dict` | funksjon | kjører modellen; se under |
| `grensetilfelle() -> list[tuple[str, float]]` | funksjon | minst ett tilfelle: (beskrivelse, største absolutte avvik mot kjernen). Hvert avvik skal være `0.0` |
| `BRUKER` | tuple, valgfri | hvilke av kjernens dicts (`"P"`, `"CH0"`, `"CH2"`, `"ENDO"`, `"SOC"`) som kan overstyres under `parametere`. Uten den: alle fem |
| `sjekk(scenario) -> None` | funksjon, valgfri | kalles før kjøringen; en `ValueError("felt: hva som er galt")` gir exit-kode 2 med meldingen din |
### Hva `simuler` får
Scenariokjøreren validerer scenariofila og gir `simuler` én kjøring om gangen (ett sveip gir flere kall), som en dict:
```python
{
"navn": "T_clear=24", # kjøringens navn ("<navn>" uten sveip)
"styring": {"regulator": "P", "forsinkelse": 3.0, "reaktivitet": 3.0},
"slutt": 120.0, # horisont i måneder
"tiltak": {...}, # tiltakene slik de står i fila (oppløst, med standardverdier)
"parametere": { # kjernens dicts, fullt utfylt med overstyringene
"P": {...}, # som sim.P, med det fila endrer
"CH0": {...}, # som sim.CH0, med Zfun, xifun og dbr satt fra tiltak
... # bare dictene i BRUKER
},
"utvidelse": {...}, # PARAMETERE med verdiene fra fila
}
```
Dictene kan sendes rett til kjernens funksjoner (`sim.deriv_chain(..., p, ch, ...)`). Typene i `"utvidelse"` er
sjekket mot standardverdiene: tall (også `"inf"`), heltall, true/false, tekst eller liste. Innholdet i en liste
sjekker du selv i `sjekk`. Feltet `sjokk` i scenariofila er ikke tillatt for utvidelser: pådraget er utvidelsens sak.
### Hva `simuler` returnerer
En dict med tidsserien `"t"` (numpy-array) og andre tidsserier med samme lengde, med kjernens navn der det gir mening
(`K`, `U`, `L`, `T`, `Q`, `tauD`, `JD`, `RD`, `Z`, `Z_rel`, `xi`, …). Kjøreren behandler dem som en kjøring av
kjernen: nøkkeltallene velges etter hvilke tidsserier som finnes, alle kan skrives til CSV, og `utdata.kolonner` kan
velge blant dem. Tall (ikke arrays) blir «skalarer» i `resultat.json`; en liste med tekst under `"log"` blir
vedtakslinjer i sammendraget.
## Hvordan utvidelser kontrolleres
- `python3 modell/kjor.py kontroll --rask` kjører `grensetilfelle()` i hver utvidelse (hver fil i
`modell/utvidelser/` som ikke begynner med `_`) og sier «Utvidelser: grensetilfellene gjenskaper kjernen eksakt
(...)», eller hvilket tilfelle som ikke gjør det.
- `tests/test_utvidelser.py` går gjennom alle utvidelsene: grensesnittet finnes, og hvert avvik er `0.0`. En ny
utvidelse er med uten at testen endres.
- `resultat.json` har sha256 for utvidelsens fil, så det er sporbart hvilken versjon en kjøring brukte.
## Gjennomført eksempel: `paadrag_tidsserie`
I kjernen er det strukturelle pådraget Ω₀ før måned 12 og Ω₀ · 1,3 etterpå (`Omega` i `sim.py`): ett varig sjokk.
Utvidelsen lar pådraget følge Ω₀ · f(t), der f er en tidsserie du oppgir. Da kan en forbigående bølge, et sjokk i
flere trinn eller en gradvis endring kjøres.
| Parameter | Standard | Hva |
|---|---|---|
| `form` | `"trapp"` | `"trapp"`: f holder verdien fra hvert punkt til neste; `"lineaer"`: rett linje mellom punktene |
| `punkter` | `[[12.0, 1.3]]` | `[måned, faktor]`, stigende måneder. Før første punkt er f = 1, etter siste er f lik siste verdi |
- Styring: P-regelen (`styring.regulator` må være `"P"`).
- Med kjeden (`parametere.CH0.Z` endelig, standard): regnestegene i `simulate_chain`, med kjernens `deriv_chain` og
pådraget gitt som `Om0`. Tiltakene virker som i `simulate_chain` (`andel_utenom`, `kapasitet`,
`trommel_buffer_tau`).
- Uten kjeden (`"CH0": {"Z": "inf"}`): regnestegene i `simulate`. Utvidelsen har en egen løkke for dette i stedet
for å la `simulate_chain` med uendelig kapasitet gjøre jobben, fordi den bare er eksakt lik `simulate` for regime A
(for regime B–D er avviket opptil 3,8·10⁻¹⁵, se [modellkart.md](modellkart.md), del (e)).
- `P.shock` og `P.t_shock` brukes ikke (pådraget følger `punkter`); endres de, er det en feil.
- Tidsserien `Omega_faktor` (f(t)) skrives med i CSV-fila.
Grensetilfellene, som `grensetilfelle()` returnerer:
| Tilfelle | Kjernen | Avvik |
|---|---|---|
| trapp med ett punkt `[12, 1.3]`, notatets `CH0` | `simulate_chain("P", 3.0, CH0)` | 0.0 (t, K, U, L, T, Q, tauD, JD, RD og tau_ref) |
| samme trapp, `CH0.Z` = inf | `simulate("P", 3.0)` | 0.0 (t, K, U, L, T) |
Testene viser i tillegg at utvidelsen kjørt gjennom scenariokjøreren med standardverdiene gir notatets
`kjede_s2.dat` og `p3.dat` byte for byte, og med tiltakene fra scenario 5 notatets `kjede_s5.dat`.
Eksempel: `eksempler/scenarioer/utvidelse_bolge.json` kjører en bølge (40 % høyere pådrag fra måned 18 til 36, med
lineær stigning og fall) med og uten flaskehals i domstolen.
## Mal
Malen under er en hel, kjørbar utvidelse: politiets effektivitet (`gamma` i fjerningsleddet) endres med en faktor
fra en gitt måned. Med faktor 1 forsvinner mekanismen, og utvidelsen gjenskaper `simulate("P", ...)` eksakt. Kopier
den til `modell/utvidelser/<ditt_navn>.py` og bytt ut mekanismen.
<!-- mal:start -->
```python
"""Utvidelse: politiets effektivitet endres med en faktor fra en gitt måned.
Bygger på simulate i sim.py (grunnmodellen uten kjede). Grensetilfelle: faktor = 1
gjenskaper simulate("P", tau) eksakt.
"""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
from typing import Any
import numpy as np
NAVN = "mal_effektivitet" # samme som filnavnet uten .py
BESKRIVELSE = "politiets effektivitet ganges med en faktor fra en gitt måned (mal)"
PARAMETERE: dict[str, object] = { # standardverdiene er grensetilfellet
"faktor": 1.0, # effektiviteten ganges med dette ...
"fra": 24.0, # ... fra denne måneden
}
BRUKER = ("P",) # kjernens dicts som kan overstyres under "parametere"
KJERNE = Path(__file__).resolve().parents[1] / "kjerne"
def _sim() -> Any:
"""Kjernen fra samme modellmappe som utvidelsen."""
if str(KJERNE) not in sys.path:
sys.path.insert(0, str(KJERNE))
return importlib.import_module("sim")
def sjekk(scenario: dict[str, Any]) -> None:
"""Valgfri: stopp med en norsk melding før kjøringen."""
if scenario["styring"]["regulator"] != "P":
raise ValueError('styring.regulator: denne utvidelsen bruker bare "P".')
if scenario["utvidelse"]["faktor"] < 0:
raise ValueError("utvidelse.faktor: må være minst 0.")
def simuler(scenario: dict[str, Any]) -> dict[str, Any]:
"""Løkka fra simulate i sim.py, med mekanismen lagt inn i fjerningsleddet."""
sim = _sim()
p = scenario["parametere"]["P"]
tau = scenario["styring"]["forsinkelse"]
kp = scenario["styring"]["reaktivitet"]
u = scenario["utvidelse"]
dt = sim.DT
n = int(scenario["slutt"] / 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"])
lag = int(round(tau / dt))
for k in range(n - 1):
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"])
# Mekanismen: med faktor 1.0 er F * 1.0 nøyaktig F, så grensetilfellet blir eksakt.
f = u["faktor"] if t[k] >= u["fra"] else 1.0
F = sim.removal(K[k], L[k], T[k], p) * f
# Resten er deriv() i sim.py, med F i stedet for removal(...). Samme rekkefølge på regnestegene.
dK = sim.Omega(t[k], p) - F - p["delta"] * K[k]
Ueq = np.clip(p["U0"] - p["psiU"] * (K[k] - 1.0), 0.0, 1.0)
dU = p["kU"] * (Ueq - U[k])
over = max(0.0, T[k] - p["Tprop"] * K[k])
dL = p["kL"] * (p["Lam0"] + p["mu1"] * (U[k] - p["U0"]) - L[k]) - p["mu2"] * L[k] * over ** 2
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)
T[-1] = T[-2]
return {"t": t, "K": K, "U": U, "L": L, "T": T}
def grensetilfelle() -> list[tuple[str, float]]:
"""Faktor 1 gjenskaper simulate i kjernen; avvikene skal være eksakt 0.0."""
sim = _sim()
ut = []
for tau in (3.0, 11.0):
scenario = {"styring": {"regulator": "P", "forsinkelse": tau, "reaktivitet": 3.0},
"slutt": float(sim.TEND), "parametere": {"P": dict(sim.P)},
"utvidelse": {"faktor": 1.0, "fra": 24.0}}
egen = simuler(scenario)
kjerne = dict(zip(("t", "K", "U", "L", "T"), sim.simulate("P", tau)))
avvik = max(float(np.abs(egen[n] - kjerne[n]).max()) for n in kjerne)
ut.append((f'mal_effektivitet: faktor 1 = simulate("P", {tau:g})', avvik))
return ut
```
<!-- mal:slutt -->
`tests/test_utvidelser.py` legger malen inn i en kopi av modellen og kontrollerer at grensetilfellene er `0.0` og at
den kan kjøres med en scenariofil, så malen virker slik den står her.
## Trinn for trinn
1. **Lag en arbeidskopi** du kan endre: `python3 modell/kjor.py arbeidskopi rettsstat-modell`. Resten gjøres i
kopien.
2. **Finn stedet i modellen** mekanismen hører til: hvilken ligning, hvilken funksjon i `sim.py`, hvilke parametere
([modellkart.md](modellkart.md)). Velg simulatoren du bygger på: den enkleste som har det mekanismen trenger.
3. **Kopier malen** til `modell/utvidelser/<navn>.py`. Sett `NAVN` lik filnavnet, skriv `BESKRIVELSE`, og legg
utvidelsens parametere i `PARAMETERE` med grensetilfellet som standardverdier.
4. **Kopier løkka** fra simulatoren du bygger på inn i `simuler`, uendret, og legg mekanismen inn slik at den
forsvinner i grensetilfellet. Det som gjør avviket eksakt 0: gang med `1.0`, legg til `0.0`, eller velg en gren
(`if`) som gjør nøyaktig det kjernen gjør. Bruk kjernens funksjoner (`removal`, `deriv_chain`, `court`,
`chain_at`, …) der du kan, i stedet for å skrive dem av. Endre ikke rekkefølgen på regnestegene: `a - b - c` og
`a - (b + c)` kan gi forskjellig siste siffer.
5. **Skriv `grensetilfelle()`**: kjør `simuler` med grenseverdiene og kjernen med de samme forutsetningene, og
returner største absolutte avvik over alle tidsseriene. Ta gjerne med flere tilfeller (andre forsinkelser, med og
uten kjede).
6. **Kontroller**: `python3 modell/kjor.py kontroll --rask` skal si at grensetilfellene gjenskaper kjernen eksakt.
Kjør testene (`python3 -m pytest tests/test_utvidelser.py`); utvidelsen er med automatisk. Skriv gjerne en egen
test for det mekanismen skal gjøre utenfor grensetilfellet.
7. **Bruk den**: skriv en scenariofil med `"modell": "utvidelse:<navn>"` og parametrene under `"utvidelse"`, og kjør
`python3 modell/kjor.py scenario <fil.json>`. Sveip virker som ellers, også over `utvidelse.<parameter>`.
8. **Send den tilbake** hvis du vil: se `BIDRA.md` i pluginen.
Vil du heller endre kjernen selv, kan du det i arbeidskopien. Da sier `kontroll` hvilke av notatets resultater som
flytter seg, og hvor mye. En utvidelse er likevel å foretrekke når mekanismen kan stå ved siden av: notatets
resultater ligger da fast, og grensetilfellet viser at de gjør det.