dynamisk-rettsstat/docs/utvidelser.md
Kjell Tore Guttormsen 776325c680
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>
2026-11-11 15:27:14 +01:00

14 KiB
Raw Permalink Blame History

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
GJENSKAPER_NOTATET tuple, valgfri navn på scenariofiler i eksempler/scenarioer/notatet/ (uten .json) som utvidelsen med standardverdiene skal gjenskape byte for byte når "modell" byttes til "utvidelse:<navn>". Testene kjører dem gjennom kjøreren og sammenligner med fasit/data

Hva simuler får

Scenariokjøreren validerer scenariofila og gir simuler én kjøring om gangen (ett sveip gir flere kall), som en dict:

{
    "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.

Før simuler kalles, sjekker kjøreren at startpunktet er en likevekt for dictene i BRUKER, på samme måte som for kjernens modeller (startlikevekt i scenarioformat.md): P for grunnmodellen, og CH0 eller CH2 for domstolen når kapasiteten er endelig. Med "startlikevekt": "kalibrer" får simuler de kalibrerte verdiene i parametere.

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.
  • Avviket grensetilfelle() rapporterer, er utvidelsens egen beregning; en utvidelse som bare returnerer 0.0, ville gått gjennom. Derfor bør utvidelsen oppgi GJENSKAPER_NOTATET: da kjører testen de scenariofilene gjennom scenariokjøreren med utvidelsen og sammenligner datafilene byte for byte med fasit/data, uavhengig av grensetilfelle(). Mangler den, sier testen fra (hoppet over, med grunn).
  • 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, 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)

Utvidelsen oppgir GJENSKAPER_NOTATET = ("kjede_s2", "kjede_s3", "kjede_s4", "kjede_s5"): kjørt gjennom scenariokjøreren med standardverdiene (og tiltakene fra scenario 3–5) gir den notatets datafiler byte for byte. Testene viser i tillegg at den med CH0.Z = inf gir p3.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.

"""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

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). 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. Oppgi GJENSKAPER_NOTATET hvis standardverdiene gjenskaper en av notatets scenariofiler, så sjekkes grensetilfellet også uavhengig av din egen grensetilfelle(). 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.