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

327
docs/scenarioformat.md Normal file
View file

@ -0,0 +1,327 @@
# Scenarioformatet
En scenariofil er en JSON-fil som sier hvilken av modellens simuleringer som skal kjøres, med hvilke forutsetninger.
Scenariokjøreren (`modell/scenario.py`) kaller kjernen i `modell/kjerne/sim.py` uendret med verdiene i fila, og
skriver et sammendrag, tidsseriene og alle parametere slik de ble brukt.
```
python3 modell/kjor.py scenario <fil.json> [--ut <mappe>]
```
Alt som ikke står i fila, er notatets verdier (tabell 3 i notatet). De er stiliserte og ikke kalibrert på norske
data, og sammendraget sier det. Tallene viser modellens dynamikk, ikke prognoser. Feltnavnene er norske; navnene på
modellens parametere er kjernens egne, slik at de kan slås opp i `sim.py` og i [modellkart.md](modellkart.md).
## Et første eksempel
```json
{
"navn": "kapasitet_tidlig",
"beskrivelse": "Hva om domstolens kapasitet økes allerede fra måned 12, ikke 24?",
"modell": "simulate_chain",
"tiltak": {"kapasitet": {"faktor": 1.34, "fra": 12, "over": 24}},
"sveip": {"parameter": "styring.forsinkelse", "verdier": [3, 6, 11]}
}
```
Dette kjører den koblede modellen med domstolskø (avsnitt 8.2) tre ganger, med forsinkelse 3, 6 og 11 måneder i
politiets styring, og med kapasiteten i domstolen økt med 34 % lineært fra måned 12 til 36. Resten er notatets
regime A og scenario 2.
## Feltene
| Felt | Type | Standard | Gjelder | Hva |
|---|---|---|---|---|
| `navn` | tekst | filnavnet uten `.json` | alle | navn på kjøringen; gir navnet på resultatmappa og filene |
| `beskrivelse` | tekst | tom | alle | hvilket spørsmål scenarioet svarer på; står øverst i sammendraget |
| `merknad` | tekst | ingen | alle | fritekst for deg selv; brukes ikke |
| `modell` | tekst | **må oppgis** | | hvilken simulator, se under |
| `styring` | objekt | regime A | alle | regulator, forsinkelse og reaktivitet for politiets innsats |
| `parametere` | objekt | notatets verdier | alle | overstyring av kjernens parametere, gruppert som i kjernen |
| `regel` | tekst | **må oppgis** for `simulate_endo` | `simulate_endo` | beslutningsregel for andel utenom og kapasitet |
| `sjokk` | objekt | notatets sjokk | alle unntatt utvidelser | størrelse og tidspunkt for makrosjokket |
| `tiltak` | objekt | ingen tiltak | `simulate_chain`, `simulate_chain2`, `simulate_endo` med regel `sched`, utvidelser med kjede | andel utenom, kapasitet, trommel–buffer–tau |
| `slutt` | tall | 120 | alle | horisont i måneder (høyst 2 400) |
| `sveip` | objekt eller liste | ingen | alle | én parameter over en liste verdier, eller to (rutenett) |
| `utdata` | objekt | CSV med alle tidsserier, én rad per måned | alle | format, kolonner og hvor tett radene står |
| `utvidelse` | objekt | utvidelsens `PARAMETERE` | `utvidelse:<navn>` | utvidelsens egne parametere |
Ukjente felt er en feil, med en melding som nevner feltet og lister de gyldige (og foreslår det nærmeste ved en
skrivefeil). Det samme gjelder ukjente parameternavn. En skrivefeil gir aldri en stille standardverdi.
Tall skrives med punktum som desimaltegn, som JSON krever. Uendelig skrives som teksten `"inf"` (i anførselstegn),
og bare der det er tillatt (står i tabellene under). `Infinity` og `NaN` er ikke gyldig JSON og avvises. Står et felt
to ganger i samme objekt, er det en feil.
### `modell`
| Verdi | Hva | Kjernens funksjon | Avsnitt |
|---|---|---|---|
| `"simulate"` | grunnmodellen uten straffesakskjede: kriminalitet K, trygghet U, tillit Λ, innsats T | `simulate(controller, tau, p, kp)` | 8.1 (regime A–E) |
| `"simulate_chain"` | domstolen som ledd med kø, koblet til grunnmodellen | `simulate_chain(controller, tau, ch, p, kp, mpc_knows_chain)` | 7.3 og 8.2 |
| `"simulate_chain2"` | domstol med to sakstyper (A alvorlige, B øvrige), forrang eller felles kø, c spor | `simulate_chain2(tau, ch2, p, kp)` | 8.3 |
| `"simulate_soc"` | rekruttering og vippepunkt, med et forbigående sjokk | `simulate_soc(controller, tau, p, s, kp, shock, t_on, t_off)` | 8.4 |
| `"simulate_endo"` | endogene tiltak: andel utenom og kapasitet bestemt av beslutningsregler | `simulate_endo(policy, tau, p, ch, e, kp, tend, xi_sched, Z_sched)` | 8.5 |
| `"utvidelse:<navn>"` | en mekanisme utenfor kjernen, i `modell/utvidelser/<navn>.py` | `simuler(scenario)` i utvidelsen | se [utvidelser.md](utvidelser.md) |
### `styring`
| Felt | Type | Standard | Gyldig | Hva |
|---|---|---|---|---|
| `regulator` | tekst | `"P"` | se under | hvordan politiets innsats T settes |
| `forsinkelse` | tall (mnd) | 3 | 0 til `slutt` | τ: fra kriminalitetsbildet endrer seg til innsatsen er endret |
| `reaktivitet` | tall | 3 | 0 til 100 | k_p: prosent mer innsats per prosent avvik fra målet (P-regelen) |
| `mpc_kjenner_kjeden` | true/false | true | bare `simulate_chain` med `"MPC"` | om MPC-ens interne modell har med køen |
Regulatorer: `"P"` (proporsjonal regel) for alle modeller; `"MPC"` (modellprediktiv styring, avsnitt 8.1, regime D)
for `simulate` og `simulate_chain`; `"fast"` (innsatsen står fast på T₀) for `simulate_soc`. Standardverdiene er
regime A i notatet. MPC er tregt: én kjøring tar om lag 7 sekunder.
### `parametere`
Overstyring av kjernens parametere, gruppert etter dicten de står i i `sim.py`. Bare dictene modellen bruker, er
tillatt:
| Gruppe | Brukes av | Nøkler |
|---|---|---|
| `P` | alle | `gamma`, `eta`, `delta`, `Omega0`, `shock`, `t_shock`, `T0`, `Tmax`, `Kstar`, `kU`, `U0`, `psiU`, `kL`, `mu1`, `mu2`, `Tprop`, `Lam0`, `ksat` |
| `CH0` | `simulate_chain`, `simulate_endo`, `utvidelse:paadrag_tidsserie` | `Z`, `m`, `theta`, `chi`, `mu4`, `tauh`, `varsigma`, `xi` |
| `CH2` | `simulate_chain2` | `Z`, `m`, `c`, `prio`, `piA`, `thetaA`, `thetaB`, `chi`, `mu4`, `tauh`, `varsigma`, `xiA`, `xiB` |
| `ENDO` | `simulate_endo` | `lagD`, `xi_min`, `tau_thr`, `k_xi`, `tau_xi`, `ups_star`, `ups_hi`, `ups_lo`, `kZ`, `L1`, `L2`, `tau_down`, `T_clear`, `H_pred`, `period`, `qloop`, `mu3`, `Lam_star`, `k_q`, `q_max`, `lag_survey` |
| `SOC` | `simulate_soc` | `eps`, `Kh`, `pexp` |
Hva hver parameter betyr, hvilken ligning den står i og notatets verdi: [modellkart.md](modellkart.md), del (b).
Listen over gyldige nøkler leses fra kjernen hver gang, så en parameter du legger til i en arbeidskopi, kan
overstyres uten å endre kjøreren.
Typen følger kjernens standardverdi: tall, heltall (`CH2.c`, `SOC.pexp`) eller true/false (`CH2.prio`,
`ENDO.qloop`). Kjøreren sjekker også at verdien gir mening:
- uendelig (`"inf"`) er tillatt for `P.ksat` (ingen politimetning), `CH0.Z` (ingen flaskehals, = regime A) og
`ENDO.T_clear` (restansen avvikles ikke);
- andeler ligger mellom 0 og 1 (`U0`, `theta`, `xi`, `xi_min`, …), og `Lam0`, `piA` og `Lam_star` strengt mellom;
- tider, forsterkninger og rater er ikke negative, og tidskonstanter og kapasiteter er større enn 0;
- `ENDO.L1` < `ENDO.L2` og `ENDO.ups_lo` < `ENDO.ups_hi`.
`CH2.m` kan settes til `"kalibrer"`: da regnes m ut med kjernens `calibrate_m`, slik at saksbehandlingstiden i
utgangslikevekten blir 3 måneder (det notatet gjør for c = 10 spor). Med flere spor (`c` > 1) og uten kalibrering
er utgangslikevekten en annen enn notatets.
Funksjonene i kjedene (`Zfun`, `xifun`, `xiBfun`) og `dbr` settes ikke her, men med `tiltak`.
Viktig: `CH0.Z` og `CH0.m` (og `CH2.Z`, `CH2.m`) er regnet ut fra `P` da `sim.py` ble lest inn (`ZD0`, `MD`). Endrer
du `P.gamma`, `P.Lam0` eller `P.T0`, regnes de ikke om; domstolen er da ikke lenger 95 % utnyttet før sjokket. Oppgi
`CH0.Z` og `CH0.m` selv om du vil ha det.
### `regel` (bare `simulate_endo`)
| Verdi | Hva (avsnitt 8.5) |
|---|---|
| `"sched"` | faste tidsplaner; med `tiltak` er det scenario 5 som vedtak (`endo_e5`), uten er det scenario 2 |
| `"desentral"` | påtalemyndigheten justerer andelen utenom etter observert saksbehandlingstid; budsjettet bestiller etter observert innstrømning |
| `"desentral_prog"` | som `desentral`, men budsjettet dimensjonerer etter prognosen |
| `"koord"` | koordinert regel: andel utenom og kapasitet ut fra en felles kapasitetsbalanse (metoden i avsnitt 7.7) |
Kvalitetssløyfen slås på med `"parametere": {"ENDO": {"qloop": true}}`. Avviklingstiden for restansen i den
koordinerte regelen er `ENDO.T_clear`.
### `sjokk`
| Felt | Type | Standard | Hva |
|---|---|---|---|
| `faktor` | tall > 0 | 1,30 (`simulate_soc`: 1,6) | det strukturelle pådraget Ω ganges med dette |
| `fra` | tall ≥ 0 (mnd) | 12 | når sjokket kommer |
| `til` | tall (mnd) | 36 | bare `simulate_soc`: når sjokket er over |
For alle modeller unntatt `simulate_soc` er sjokket varig, og `faktor` og `fra` er det samme som `P.shock` og
`P.t_shock`; oppgis begge steder, er det en feil. I `simulate_soc` er sjokket forbigående og settes bare her. Vil du
ha et forbigående sjokk, en bølge eller flere trinn i de andre modellene, bruk utvidelsen `paadrag_tidsserie`
([utvidelser.md](utvidelser.md)); utvidelser tar ikke `sjokk`.
### `tiltak`
| Felt | Type | Standard | Hva | Notatets verdi |
|---|---|---|---|---|
| `andel_utenom.andel` | tall i [0, 1) | må oppgis | andelen av sakene som ledes til forenklede reaksjoner | 0,2 |
| `andel_utenom.fra` | tall ≥ 0 (mnd) | 24 | fra hvilken måned | 24 |
| `kapasitet.faktor` | tall > 0 | må oppgis | domstolens kapasitet ganges med dette til slutt | 1,34 |
| `kapasitet.fra` | tall ≥ 0 (mnd) | 24 | økningen begynner | 24 |
| `kapasitet.over` | tall > 0 (mnd) | 24 | økningen fordeles lineært over så mange måneder | 24 |
| `trommel_buffer_tau` | tall > 0 | ingen | politiets innsats begrenses slik at strømmen til domstolen er høyst dette ganger kapasiteten | 0,95 |
Hvor tiltakene virker:
- `simulate_chain`: andelen blir `xifun` (ξ_D = 1 − andel fra måned `fra`), kapasiteten `Zfun`
(Z_D = Z · (1 + (faktor − 1) · min(max((t − fra)/over, 0), 1)), samme regneuttrykk som `Z_utv` i `sim.py`), og
trommel–buffer–tau `dbr`. Scenario 3, 4 og 5 i notatet.
- `simulate_chain2`: andelen er av alle saker og tas fra klasse B, som i notatet: ξ_B = 1 − andel/(1 − `piA`).
Andelen kan derfor være høyst 1 − `piA`.
- `simulate_endo`: bare med regel `"sched"`; tiltakene blir tidsplanene `xi_sched` og `Z_sched`. De andre reglene
bestemmer andel og kapasitet selv. `trommel_buffer_tau` brukes ikke her.
- Kapasitet og trommel–buffer–tau gir ingen mening når `CH0.Z` er `"inf"`, og avvises.
### `slutt`
Horisont i måneder, større enn 0 og høyst 2 400. Standard er 120 (`TEND` i `sim.py`); notatets kjøringer med
`simulate_endo` bruker 180. For simulatorene som leser den globale `TEND`, setter kjøreren `sim.TEND` midlertidig og
setter den tilbake etterpå; `simulate_endo` får den som argumentet `tend`. Tidssteget er alltid `DT` = 0,05 måned.
### `sveip`
Ett sveip kjører samme scenario én gang per verdi:
```json
"sveip": {"parameter": "parametere.ENDO.T_clear", "verdier": [12, 24, 48, "inf"]}
```
To sveip i en liste gir et rutenett (alle kombinasjoner, den første parameteren ytterst):
```json
"sveip": [
{"parameter": "styring.reaktivitet", "verdier": [1, 2, 3]},
{"parameter": "styring.forsinkelse", "verdier": [3, 6, 11]}
]
```
`parameter` er en sti med punktum inn i scenariofila. Stien kan begynne med `styring`, `parametere`, `regel`,
`sjokk`, `tiltak`, `slutt` eller `utvidelse`, og må ende i én verdi, for eksempel:
| Sti | Hva |
|---|---|
| `styring.reaktivitet`, `styring.forsinkelse`, `styring.regulator` | styringen |
| `parametere.P.ksat`, `parametere.CH0.Z`, `parametere.ENDO.T_clear`, `parametere.ENDO.qloop`, … | en av kjernens parametere |
| `regel` | beslutningsregelen i `simulate_endo` |
| `sjokk.faktor`, `sjokk.fra`, `sjokk.til` | sjokket |
| `tiltak.kapasitet.faktor`, `tiltak.andel_utenom.andel`, `tiltak.trommel_buffer_tau`, … | tiltakene |
| `slutt` | horisonten |
| `utvidelse.punkter`, … | en av utvidelsens parametere |
Regler:
- Verdien i sveipet erstatter verdien i fila for den stien. Hver kombinasjon valideres som om den sto i fila, og en
feil sier hvilken kombinasjon den gjelder.
- `null` som verdi betyr kjernens verdi (feltet tas bort), for eksempel `"verdier": [null, "inf"]` for notatets
kapasitet og ingen flaskehals. Den vises som «standard».
- Høyst to parametere og høyst 400 kjøringer. Samme verdi to ganger (også 3 og 3.0) er en feil.
- Kjøringene heter `<siste del av stien>=<verdi>`, i et rutenett skilt med `_`, for eksempel `T_clear=24` og
`reaktivitet=3_forsinkelse=11`. Filene heter `<navn>__<kjøring>.csv`.
### `utdata`
| Felt | Type | Standard | Hva |
|---|---|---|---|
| `format` | `"csv"` eller `"notat"` | `"csv"` | filformatet for tidsseriene |
| `kolonner` | liste med navn | alle tidsseriene modellen gir | hvilke tidsserier, i hvilken rekkefølge |
| `hvert` | heltall ≥ 1 | 20 (`csv`), 10 (`notat`) | hvert så mange tidssteg skrives (20 = én rad per måned) |
| `overskrift` | tekst | kolonnenavnene | bare `"notat"`: overskriftslinjen, ett ord per kolonne |
`"csv"`: komma mellom feltene, punktum som desimaltegn, én overskriftslinje med kolonnenavnene og tallene med ti
gjeldende sifre. Leses direkte av R, Python, regneark og pgfplots (`col sep=comma`). Fila heter `<navn>.csv`.
`"notat"`: nøyaktig notatets filformat, skrevet med kjernens egen `write_csv`: mellomrom mellom feltene, `%.5f`,
hver tiende rad, og overskriften du gir. Fila heter `<navn>.dat`. Med dette formatet gjenskaper scenariofilene i
`eksempler/scenarioer/notatet/` notatets datafiler, og notatets figurer kan lages på nytt fra dem (pgfplots i
LaTeX-kilden leser filene direkte).
## Tidsseriene per modell
| Modell | Tidsserier (kolonnenavn) |
|---|---|
| `simulate`, `simulate_soc` | `t K U L T` |
| `simulate_chain` | `t K U L T Q tauD JD RD xi`, og `Z Z_rel` når kapasiteten er endelig |
| `simulate_chain2` | `t K KA KB U L T QA QB tauA tauB RA RB` |
| `simulate_endo` | `t K U L T Q tauD JD RD Z xi q Z_rel` |
| `utvidelse:paadrag_tidsserie` | som `simulate_chain` (med kjede) eller `simulate` (uten), og `Omega_faktor` |
`t` er tid i måneder, `K` kriminalitet (1 = før sjokket), `U` trygghet, `L` tillit Λ (0,80 før sjokket), `T`
politiets innsats (1 = før sjokket), `Q` køen i domstolen, `tauD` saksbehandlingstiden (mnd), `JD` saker inn og `RD`
saker avgjort per måned, `xi` andelen som går til domstolen (1 − andel utenom), `Z` kapasiteten og `Z_rel` kapasiteten
delt på kapasiteten ved start, `q` prosessuell kvalitet. I `simulate_chain2` er `KA`/`KB`, `QA`/`QB`, `tauA`/`tauB` og
`RA`/`RB` det samme per sakstype. Kø, strøm og kapasitet er i modellens normaliserte enheter. `Z`, `xi` og `Z_rel` i
`simulate_chain` regnes ut av kjøreren fra kjernens `chain_at`; `Z_rel` i `simulate_endo` er det notatets `endo_*.dat`
kaller `Z`.
## Hva kjøreren skriver
I mappa `resultater/<navn>/` under mappa du står i, eller i mappa du gir med `--ut`:
- `sammendrag.md`: hva som ble kjørt, hvilke felt fila satte selv, nøkkeltallene i en tabell (én kolonne per
kjøring, høyst seks per tabell), for et rutenett også en tabell med ett nøkkeltall over begge parametrene (spennet
i K de siste 60 månedene når horisonten er over 120 mnd, ellers spennet etter måned 60), og for
`simulate_endo` budsjettvedtakene fra regelen. Det samme skrives til skjermen, sammen med stien til mappa.
- én CSV (eller `.dat`) per kjøring;
- `resultat.json`: scenarioets navn og fil med sha256, Python-, numpy- og scipy-versjon, sha256 for de tre filene i
kjernen (og for utvidelsen), og for hver kjøring alle parametere slik simulatoren fikk dem (fullt utfylt, også det
fila ikke satte), nøkkeltallene med full presisjon og budsjettvedtakene. Uendelig står som `"inf"`.
Samme scenariofil gir samme bytes i alle tre filene på samme maskin og med samme versjoner: det står ingen
tidspunkter i dem. Finnes mappa fra før med et tidligere resultat, tas bare filene det resultatet laget bort. Finnes
den med andre filer, stopper kjøreren uten å skrive noe.
## Nøkkeltallene
Fra det kjernens `summary`, `summary2` og `summary_endo` skriver ut, med norske navn og norsk tallformat. Et
nøkkeltall vises bare når modellen har tidsserien det bygger på.
| Nøkkel i `resultat.json` | Hva |
|---|---|
| `K_snitt` | kriminalitet, gjennomsnitt fra måned 12 (bare `simulate_endo`, som `Kmean`) |
| `K_maks`, `K_slutt` | kriminalitet, høyeste og ved slutt |
| `spenn_K` | høyeste minus laveste K etter måned 60 (`amp(K,t>60)` i `sim.py`) |
| `spenn_K_sist` | høyeste minus laveste K de siste 60 månedene, når horisonten er over 120 mnd: vedvarende svingning |
| `KA_slutt`, `KB_slutt` | belastning per sakstype ved slutt (`simulate_chain2`) |
| `T_maks`, `T_slutt` | politiets innsats |
| `L_min`, `L_slutt` | tillit |
| `tauD_maks`, `tauD_slutt` (og `tauA_*`, `tauB_*`) | saksbehandlingstid, mnd |
| `Q_slutt` (og `QA_slutt`, `QB_slutt`) | kø ved slutt |
| `inn_ut_slutt` | saker inn delt på saker avgjort ved slutt (`ups_end` i `summary`) |
| `Z_maks`, `Z_slutt` | kapasitet, 1,00 = ved start (når kapasiteten endres) |
| `utenom_maks_pst`, `utenom_slutt_pst` | andel ledet utenom, prosent (når den endres) |
| `q_maks` | prosessuell kvalitet (når kvalitetssløyfen er på) |
| `frafall`, `kapasitetsaar` | frafall fra køen summert over tid, i måneder av innstrømningen før sjokket (som i notatets tabell i avsnitt 8.5; `summary_endo` skriver det i modellens enheter, en tidel av dette), og kapasitetsår over utgangsnivået (`simulate_endo`) |
Under tabellen står: 1,00 er nivået i referanselikevekten før sjokket for K og T, tilliten er 0,80 der, kapasitet
1,00 er kapasiteten ved start, tider er i måneder.
## Feil og exit-koder
Exit-kode 0 når alt gikk, 2 ved feil i scenariofila eller bruken. Meldingen nevner feltet:
```
Feil i scenariofila mitt.json: parametere.P.gama: ukjent parameter i P. Mente du "gamma"? Gyldige: gamma, eta, ...
Feil i scenariofila mitt.json: styring.forsinkelse: må være et tall minst 0 og høyst 120, ikke "tre".
Feil i scenariofila mitt.json: sveip[0].parameter: "styring.reaktivtet": ukjent felt "reaktivtet" under styring. ...
Fila mitt.json er ikke gyldig JSON: feil på linje 2, kolonne 15.
```
Stopper en kjøring i kjernen (en kombinasjon uten likevekt ved start eller med en løsning som vokser over alle
grenser), sier meldingen hvilken kjøring det var, og ingenting skrives.
## Eksempelfilene
### Notatets datafiler: `eksempler/scenarioer/notatet/`
Hver fil gjenskaper en av notatets datafiler med `"format": "notat"`. Testene i `tests/test_scenario.py` sammenligner.
| Notatets fil | Scenariofil | Gjenskapt |
|---|---|---|
| `p3`, `p6`, `p11`, `p11d`, `mpc11` | samme navn | byte for byte |
| `kjede_s1` … `kjede_s5` | samme navn | byte for byte |
| `endo_e5`, `endo_des`, `endo_desp`, `endo_ko` | samme navn | byte for byte |
| `sat_B`, `sat_C` | samme navn (sveip over `P.ksat`: `inf`, 3, 1,5) | kolonne for kolonne |
| `prio_s2`, `prio_s3`, `prio_s5` | samme navn (sveip over `CH2.prio`: med og uten forrang) | kolonne for kolonne |
| `vippe` | `vippe.json` (fast, p3, p6) og `vippe_tak.json` (p3cap, p6cap) | kolonne for kolonne |
«Kolonne for kolonne»: notatets fil setter sammen kolonner fra flere kjøringer; hver kolonne er lik tegn for tegn
den tilsvarende kolonnen i kjøringens fil. `stab.dat` og `thumb.dat` er analytiske kart (`critical_delay`), ikke
simuleringer, og har ingen scenariofil.
### Spørsmål notatet ikke har figur for: `eksempler/scenarioer/`
| Fil | Spørsmål |
|---|---|
| `avviklingstid_koordinert.json` | hvor raskt bør restansen avvikles i den koordinerte regelen (T_clear fra 6 til 96 mnd og uendelig)? |
| `stabilitet_reaktivitet_forsinkelse.json` | hvor går grensen for svingninger, over reaktivitet og forsinkelse, sammenlignet med den kritiske forsinkelsen? |
| `sjokkstorrelse_kjede.json` | hvor mye betyr størrelsen på sjokket når domstolen er en flaskehals? |
| `kvalitetssloyfe.json` | hva gjør kvalitetssløyfen med desentraliserte og koordinerte regler? |
| `utvidelse_bolge.json` | hva etterlater en forbigående bølge i pådraget, med og uten flaskehals (utvidelsen `paadrag_tidsserie`)? |

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.