dynamisk-rettsstat/docs/utvidelser.md
Kjell Tore Guttormsen 03151cfb3b
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>
2026-11-11 14:17:32 +01:00

13 KiB
Raw 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

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.

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, 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.

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