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:
parent
464ad38b49
commit
03151cfb3b
36 changed files with 3580 additions and 5 deletions
229
docs/utvidelser.md
Normal file
229
docs/utvidelser.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue