dynamisk-rettsstat/modell/scenario.py
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

1508 lines
63 KiB
Python

# Copyright (c) 2026 Kjell Tore Guttormsen. MIT License, see LICENSE.
"""Scenariokjøreren: kjør modellens simuleringer fra en scenariofil (JSON).
Bruk:
python3 modell/kjor.py scenario <fil.json> [--ut <mappe>]
python3 modell/scenario.py <fil.json> [--ut <mappe>]
Formatet er beskrevet i docs/scenarioformat.md. Kjøreren bruker kjernen i
modell/kjerne/ (notatets sim.py) uendret: den kaller simulatorene der med
parametrene fra fila, setter horisonten for simulatorene som bruker den globale
TEND midlertidig, og skriver sammendrag.md, én CSV per kjøring og resultat.json.
Utvidelser i modell/utvidelser/ kjøres med "modell": "utvidelse:<navn>".
"""
from __future__ import annotations
import copy
import difflib
import hashlib
import importlib
import importlib.util
import itertools
import json
import math
import re
import sys
from contextlib import contextmanager
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, NamedTuple, NoReturn
import numpy as np
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
from types import ModuleType
import numpy.typing as npt
Arr = npt.NDArray[np.float64]
MODELLMAPPE = Path(__file__).resolve().parent
KJERNE = MODELLMAPPE / "kjerne"
UTVIDELSER = MODELLMAPPE / "utvidelser"
KJERNEFILER = ("sim.py", "diagnose.py", "forlop.py")
MODELLER = ("simulate", "simulate_chain", "simulate_chain2", "simulate_soc", "simulate_endo")
UTVIDELSE_PREFIKS = "utvidelse:"
MODELLTEKST = {
"simulate": "aggregert modell uten straffesakskjede (avsnitt 8.1, regime A-E)",
"simulate_chain": "koblet modell med domstolskø (avsnitt 7.3 og 8.2)",
"simulate_chain2": "domstol med to sakstyper, forrang og flere spor (avsnitt 8.3)",
"simulate_soc": "rekruttering og vippepunkt med forbigående sjokk (avsnitt 8.4)",
"simulate_endo": "endogene tiltak: beslutningsregler for andel utenom og kapasitet (avsnitt 8.5)",
}
DICTS_FOR_MODELL = {
"simulate": ("P",),
"simulate_chain": ("P", "CH0"),
"simulate_chain2": ("P", "CH2"),
"simulate_soc": ("P", "SOC"),
"simulate_endo": ("P", "CH0", "ENDO"),
}
ALLE_DICTS = ("P", "CH0", "CH2", "ENDO", "SOC")
REGULATORER = {
"simulate": ("P", "MPC"),
"simulate_chain": ("P", "MPC"),
"simulate_chain2": ("P",),
"simulate_soc": ("P", "fast"),
"simulate_endo": ("P",),
}
REGULATORTEKST = {"P": "proporsjonal regel (P)", "MPC": "modellprediktiv styring (MPC)",
"fast": "fast innsats (T = T0)"}
REGLER = ("sched", "desentral", "desentral_prog", "koord")
TILTAK_FOR_MODELL = {
"simulate": (),
"simulate_chain": ("andel_utenom", "kapasitet", "trommel_buffer_tau"),
"simulate_chain2": ("andel_utenom", "kapasitet", "trommel_buffer_tau"),
"simulate_soc": (),
"simulate_endo": ("andel_utenom", "kapasitet"),
}
# Nøkler i kjernens dicts som ikke er tall, men settes av tiltakene.
SATT_AV_TILTAK = {
("CH0", "Zfun"): "tiltak.kapasitet",
("CH0", "xifun"): "tiltak.andel_utenom",
("CH0", "dbr"): "tiltak.trommel_buffer_tau",
("CH2", "Zfun"): "tiltak.kapasitet",
("CH2", "xiBfun"): "tiltak.andel_utenom",
}
TOPPFELT = ("navn", "beskrivelse", "merknad", "modell", "styring", "parametere", "regel", "sjokk",
"tiltak", "slutt", "sveip", "utdata", "utvidelse")
SVEIPBARE = ("styring", "parametere", "regel", "sjokk", "tiltak", "slutt", "utvidelse")
STYRINGSFELT = ("regulator", "forsinkelse", "reaktivitet", "mpc_kjenner_kjeden")
SJOKKFELT = ("faktor", "fra", "til")
TILTAKSFELT = ("andel_utenom", "kapasitet", "trommel_buffer_tau")
ANDELFELT = ("andel", "fra")
KAPASITETSFELT = ("faktor", "fra", "over")
UTDATAFELT = ("format", "kolonner", "hvert", "overskrift")
SVEIPFELT = ("parameter", "verdier")
FORMATER = ("csv", "notat")
STANDARD_FORSINKELSE = 3.0 # regime A i notatet
STANDARD_REAKTIVITET = 3.0
STANDARD_TILTAK_FRA = 24.0 # T_TILTAK i sim.py
STANDARD_TILTAK_OVER = 24.0 # T_UTV i sim.py
STANDARD_SOC_SJOKK = (1.6, 12.0, 36.0) # simulate_soc: shock, t_on, t_off
MAKS_SLUTT = 2400.0
MAKS_KJORINGER = 400
RUTENETT = 2 # høyst to parametere i et sveip
SEN_FASE = 60.0 # spennet i K måles etter denne måneden (som amp(K,t>60) i sim.py)
SNITT_FRA = 12.0 # summary_endo: gjennomsnitt fra måned 12
MAKS_KOLONNER_I_TABELL = 6
CSV_HVERT = 20 # standard: én rad per måned (DT = 0,05)
NOTAT_HVERT = 10 # write_csv i sim.py
class ScenarioFeil(Exception):
"""Feil i scenariofila eller i bruken; gir exit-kode 2."""
class FeltFeil(ScenarioFeil):
"""Feil i et bestemt felt i scenariofila."""
class Grense(NamedTuple):
"""Gyldig område for et tall."""
nedre: float | None = None
nedre_med: bool = True
ovre: float | None = None
ovre_med: bool = True
inf: bool = False
IKKE_NEG = Grense(0.0)
POSITIV = Grense(0.0, nedre_med=False)
ANDEL = Grense(0.0, ovre=1.0)
ANDEL_POS = Grense(0.0, nedre_med=False, ovre=1.0)
APEN_ANDEL = Grense(0.0, nedre_med=False, ovre=1.0, ovre_med=False)
POSITIV_INF = Grense(0.0, nedre_med=False, inf=True)
GRENSER: dict[tuple[str, str], Grense] = {
("P", "gamma"): IKKE_NEG, ("P", "eta"): IKKE_NEG, ("P", "delta"): IKKE_NEG,
("P", "Omega0"): IKKE_NEG, ("P", "shock"): POSITIV, ("P", "t_shock"): IKKE_NEG,
("P", "T0"): IKKE_NEG, ("P", "Tmax"): POSITIV, ("P", "Kstar"): POSITIV,
("P", "kU"): IKKE_NEG, ("P", "U0"): ANDEL, ("P", "psiU"): IKKE_NEG, ("P", "kL"): IKKE_NEG,
("P", "mu1"): IKKE_NEG, ("P", "mu2"): IKKE_NEG, ("P", "Tprop"): IKKE_NEG,
("P", "Lam0"): APEN_ANDEL, ("P", "ksat"): POSITIV_INF,
("CH0", "Z"): POSITIV_INF, ("CH0", "m"): IKKE_NEG, ("CH0", "theta"): ANDEL,
("CH0", "chi"): IKKE_NEG, ("CH0", "mu4"): IKKE_NEG, ("CH0", "tauh"): POSITIV,
("CH0", "varsigma"): IKKE_NEG, ("CH0", "xi"): ANDEL_POS,
("CH2", "Z"): POSITIV, ("CH2", "m"): IKKE_NEG, ("CH2", "piA"): APEN_ANDEL,
("CH2", "thetaA"): ANDEL, ("CH2", "thetaB"): ANDEL, ("CH2", "chi"): IKKE_NEG,
("CH2", "mu4"): IKKE_NEG, ("CH2", "tauh"): POSITIV, ("CH2", "varsigma"): IKKE_NEG,
("CH2", "xiA"): ANDEL, ("CH2", "xiB"): ANDEL,
("ENDO", "lagD"): IKKE_NEG, ("ENDO", "xi_min"): ANDEL_POS, ("ENDO", "tau_thr"): POSITIV,
("ENDO", "k_xi"): IKKE_NEG, ("ENDO", "tau_xi"): POSITIV, ("ENDO", "ups_star"): POSITIV,
("ENDO", "ups_hi"): POSITIV, ("ENDO", "ups_lo"): IKKE_NEG, ("ENDO", "kZ"): IKKE_NEG,
("ENDO", "L1"): IKKE_NEG, ("ENDO", "L2"): POSITIV, ("ENDO", "tau_down"): POSITIV,
("ENDO", "T_clear"): POSITIV_INF, ("ENDO", "H_pred"): POSITIV, ("ENDO", "period"): POSITIV,
("ENDO", "mu3"): IKKE_NEG, ("ENDO", "Lam_star"): APEN_ANDEL, ("ENDO", "k_q"): IKKE_NEG,
("ENDO", "q_max"): IKKE_NEG, ("ENDO", "lag_survey"): IKKE_NEG,
("SOC", "eps"): IKKE_NEG, ("SOC", "Kh"): POSITIV,
}
HELTALLSGRENSER = {("CH2", "c"): (1, 50), ("SOC", "pexp"): (1, 20)}
# --------------------------------------------------------------------------- kjernen
def fra_kjernen(modul: ModuleType | None) -> bool:
"""Sann hvis modulen er lastet fra kjerne/ i denne modellmappa."""
return modul is not None and Path(str(getattr(modul, "__file__", ""))).resolve().parent == KJERNE
def last_kjerne() -> ModuleType:
"""Importer notatets sim.py fra kjerne/ ved siden av denne fila (aldri fra en annen modellmappe)."""
if fra_kjernen(sys.modules.get("sim")):
return sys.modules["sim"]
sys.modules.pop("sim", None)
if str(KJERNE) in sys.path:
sys.path.remove(str(KJERNE))
sys.path.insert(0, str(KJERNE))
sim = importlib.import_module("sim")
kilde = Path(str(sim.__file__)).resolve().parent
if kilde != KJERNE:
melding = (f"sim.py er allerede lastet fra {kilde}, ikke fra {KJERNE}. Kjør hver modellmappe "
"i sin egen prosess.")
raise ScenarioFeil(melding)
return sim
def last_utvidelse(navn: str) -> ModuleType:
"""Last modell/utvidelser/<navn>.py fra denne modellmappa og kontroller grensesnittet."""
tilgjengelige = sorted(p.stem for p in UTVIDELSER.glob("*.py") if not p.stem.startswith("_"))
if not re.fullmatch(r"[a-z_][a-z0-9_]*", navn) or navn not in tilgjengelige:
feil("modell", f'fant ikke utvidelsen "{navn}" i {UTVIDELSER}. Tilgjengelige: '
+ (", ".join(f"utvidelse:{n}" for n in tilgjengelige) or "ingen") + ".")
last_kjerne()
fil = UTVIDELSER / f"{navn}.py"
modulnavn = f"utvidelser.{navn}"
gammel = sys.modules.get(modulnavn)
if gammel is not None and Path(str(getattr(gammel, "__file__", ""))).resolve() == fil:
modul = gammel
else:
spec = importlib.util.spec_from_file_location(modulnavn, fil)
if spec is None or spec.loader is None:
feil("modell", f"kunne ikke laste {fil}.")
modul = importlib.util.module_from_spec(spec)
sys.modules[modulnavn] = modul
spec.loader.exec_module(modul)
mangler = [a for a in ("NAVN", "BESKRIVELSE", "PARAMETERE", "simuler", "grensetilfelle")
if not hasattr(modul, a)]
if mangler or not isinstance(modul.PARAMETERE, dict):
feil("modell", f'utvidelsen "{navn}" følger ikke grensesnittet i docs/utvidelser.md: mangler '
+ (", ".join(mangler) if mangler else "PARAMETERE som objekt") + ".")
return modul
def sha256(sti: Path) -> str:
"""Sjekksum for en fil."""
return hashlib.sha256(sti.read_bytes()).hexdigest()
@contextmanager
def horisont(sim: ModuleType, slutt: float) -> Iterator[None]:
"""Sett sim.TEND midlertidig (simulate, simulate_chain, simulate_chain2, simulate_soc)."""
kjerne: Any = sim # modulattributtet settes; ModuleType tillater bare lesing for mypy
gammel = kjerne.TEND
kjerne.TEND = slutt
try:
yield
finally:
kjerne.TEND = gammel
# --------------------------------------------------------------------------- tall og tekst
def tall(x: float | None, d: int = 2) -> str:
"""Norsk tallformat (som i kjernen): mellomrom som tusenskille, komma som desimaltegn."""
if x is None or math.isnan(x):
return "-"
return f"{x:,.{d}f}".replace(",", " ").replace(".", ",")
def tall_kort(x: float) -> str:
"""Kort tall i meldinger og overskrifter."""
return (str(int(x)) if float(x).is_integer() else repr(x)).replace(".", ",")
def vis(verdi: object) -> str:
"""Verdien slik den står i JSON (til meldinger)."""
return json.dumps(verdi, ensure_ascii=False)
def vis_verdi(verdi: object) -> str:
"""Kort visning av en sveipverdi i tabeller (norsk desimaltegn)."""
if verdi is None:
return "standard"
if isinstance(verdi, bool):
return "ja" if verdi else "nei"
if isinstance(verdi, float):
return repr(verdi).replace(".", ",")
if isinstance(verdi, (int, str)):
return str(verdi)
return vis(verdi)
def filverdi(verdi: object) -> str:
"""Sveipverdi i et filnavn."""
if verdi is None:
tekst_ = "standard"
elif isinstance(verdi, bool):
tekst_ = "true" if verdi else "false"
elif isinstance(verdi, float):
tekst_ = repr(verdi)
else:
tekst_ = vis(verdi).strip('"')
return re.sub(r"[^A-Za-z0-9.+-]+", "-", tekst_).strip("-") or "x"
def slug(navn: str) -> str:
"""Filnavn fra et scenarionavn."""
for a, b in (("æ", "ae"), ("ø", "o"), ("å", "aa"), ("Æ", "AE"), ("Ø", "O"), ("Å", "AA")):
navn = navn.replace(a, b)
return re.sub(r"[^A-Za-z0-9._-]+", "-", navn).strip("-.") or "scenario"
def lignende(navn: str, gyldige: list[str] | tuple[str, ...]) -> str:
"""Forslag ved skrivefeil."""
treff = difflib.get_close_matches(navn, list(gyldige), n=1, cutoff=0.6)
return f' Mente du "{treff[0]}"?' if treff else ""
def json_tall(f: float) -> float | str:
"""Tall i resultat.json; uendelig og nan som tekst."""
if math.isnan(f):
return "nan"
if math.isinf(f):
return "inf" if f > 0 else "-inf"
return f
def json_verdi(x: object) -> object:
"""Gjør en verdi klar for json.dumps (inf og nan som tekst, numpy som Python)."""
if isinstance(x, dict):
return {str(k): json_verdi(v) for k, v in x.items()}
if isinstance(x, (list, tuple)):
return [json_verdi(v) for v in x]
if isinstance(x, (bool, np.bool_)):
return bool(x)
if isinstance(x, (int, np.integer)):
return int(x)
if isinstance(x, (float, np.floating)):
return json_tall(float(x))
return x
# --------------------------------------------------------------------------- validering
def feil(sti: str, melding: str) -> NoReturn:
"""Stopp med en feil som nevner feltet."""
tekst_ = f"{sti}: {melding}" if sti else melding
raise FeltFeil(tekst_)
def objekt(verdi: object, sti: str, gyldige: tuple[str, ...] | list[str]) -> dict[str, object]:
"""Krev et objekt med bare kjente felt."""
if not isinstance(verdi, dict):
feil(sti, f"må være et objekt {{ ... }}, ikke {vis(verdi)}.")
for nokkel in verdi:
if nokkel not in gyldige:
hvor = f"{sti}.{nokkel}" if sti else str(nokkel)
feil(hvor, "ukjent felt." + lignende(str(nokkel), gyldige)
+ " Gyldige felt her: " + ", ".join(gyldige) + ".")
return verdi
def beskriv(g: Grense) -> str:
"""Tekst for et område."""
deler = []
if g.nedre is not None:
deler.append(("minst " if g.nedre_med else "større enn ") + tall_kort(g.nedre))
if g.ovre is not None:
deler.append(("høyst " if g.ovre_med else "mindre enn ") + tall_kort(g.ovre))
tekst_ = "et tall" + (" " + " og ".join(deler) if deler else "")
return tekst_ + (' (eller "inf" for uendelig)' if g.inf else "")
def tallverdi(verdi: object, sti: str, g: Grense) -> float:
"""Les et tall, med "inf" der det er tillatt."""
if isinstance(verdi, str) and verdi.strip().lower() in {"inf", "uendelig"}:
if not g.inf:
feil(sti, f"uendelig er ikke tillatt her; oppgi {beskriv(g)}.")
return math.inf
if isinstance(verdi, bool) or not isinstance(verdi, (int, float)):
feil(sti, f"må være {beskriv(g)}, ikke {vis(verdi)}.")
x = float(verdi)
ok = math.isfinite(x)
if ok and g.nedre is not None:
ok = x >= g.nedre if g.nedre_med else x > g.nedre
if ok and g.ovre is not None:
ok = x <= g.ovre if g.ovre_med else x < g.ovre
if not ok:
feil(sti, f"må være {beskriv(g)}, ikke {vis(verdi)}.")
return x
def heltall(verdi: object, sti: str, nedre: int, ovre: int) -> int:
"""Les et heltall i et område."""
if isinstance(verdi, bool) or not isinstance(verdi, int) or not nedre <= verdi <= ovre:
feil(sti, f"må være et heltall fra {nedre} til {ovre}, ikke {vis(verdi)}.")
return verdi
def sannhet(verdi: object, sti: str) -> bool:
"""Les true/false."""
if not isinstance(verdi, bool):
feil(sti, f"må være true eller false, ikke {vis(verdi)}.")
return verdi
def tekst(verdi: object, sti: str, valg: tuple[str, ...] | None = None) -> str:
"""Les tekst, eventuelt ett av gitte valg."""
if not isinstance(verdi, str) or (valg is not None and verdi not in valg):
hva = "ett av: " + ", ".join(f'"{v}"' for v in valg) if valg else "tekst i anførselstegn"
hint = lignende(verdi, valg) if isinstance(verdi, str) and valg else ""
feil(sti, f"må være {hva}, ikke {vis(verdi)}.{hint}")
return verdi
def tallfelt(d: dict[str, object], nokkel: str) -> float:
"""Et tall fra en oppløst dict (verdiene er validert før)."""
x = d[nokkel]
if isinstance(x, (int, float)) and not isinstance(x, bool):
return float(x)
melding = f"forventet et tall i {nokkel}, fikk {x!r}"
raise TypeError(melding)
def les_fil(fil: Path) -> dict[str, object]:
"""Les scenariofila som JSON, med feilmeldinger som i diagnose.les."""
def ingen_dubletter(par: list[tuple[str, object]]) -> dict[str, object]:
ut: dict[str, object] = {}
for nokkel, verdi in par:
if nokkel in ut:
feil(nokkel, "feltet står to ganger i samme objekt.")
ut[nokkel] = verdi
return ut
def konstant(navn: str) -> object:
melding = f'{navn} er ikke gyldig JSON. Skriv uendelig som "inf" (i anførselstegn).'
raise ScenarioFeil(melding)
try:
innhold = fil.read_text(encoding="utf-8")
except FileNotFoundError:
melding = f"Fant ikke fila {fil}."
raise ScenarioFeil(melding) from None
except UnicodeDecodeError:
melding = f"Fila {fil} er ikke lagret som UTF-8."
raise ScenarioFeil(melding) from None
except OSError:
melding = f"Kunne ikke lese {fil}. Er det en mappe, eller mangler du lesetilgang?"
raise ScenarioFeil(melding) from None
try:
data = json.loads(innhold, object_pairs_hook=ingen_dubletter, parse_constant=konstant)
except json.JSONDecodeError as e:
melding = f"Fila {fil} er ikke gyldig JSON: feil på linje {e.lineno}, kolonne {e.colno}."
raise ScenarioFeil(melding) from None
if not isinstance(data, dict):
melding = "Scenariofila må være ett JSON-objekt { ... } med feltene i docs/scenarioformat.md."
raise ScenarioFeil(melding)
return data
# --------------------------------------------------------------------------- kjøringer
@dataclass
class Kjoring:
"""Én fullt oppløst kjøring: alle verdier slik simulatoren får dem."""
etikett: str
sveipverdier: list[tuple[str, object]]
modell: str
regulator: str
forsinkelse: float
reaktivitet: float
mpc_kjenner_kjeden: bool | None
regel: str | None
slutt: float
sjokk: dict[str, float]
tiltak: dict[str, object]
dicts: dict[str, dict[str, object]]
utvidelse: dict[str, object] = field(default_factory=dict)
@dataclass
class Scenario:
"""Scenariofila etter validering."""
navn: str
beskrivelse: str
modell: str
format: str
kolonner: list[str] | None
hvert: int
overskrift: str | None
sveip: list[tuple[str, list[object]]]
valgt: list[str]
kjoringer: list[Kjoring]
utvidelse: ModuleType | None = None
class Variant(NamedTuple):
"""Navnet på en kjøring og verdiene sveipet ga den."""
etikett: str
sveipverdier: list[tuple[str, object]]
def modellnavn(verdi: object) -> str:
"""Valider "modell"."""
if isinstance(verdi, str) and (verdi in MODELLER or verdi.startswith(UTVIDELSE_PREFIKS)):
return verdi
hint = lignende(verdi, MODELLER) if isinstance(verdi, str) else ""
feil("modell", f"må være ett av: {', '.join(MODELLER)} eller utvidelse:<navn>, "
f"ikke {vis(verdi)}.{hint}")
def parameterdicts(sim: ModuleType, modell: str, utv: ModuleType | None) -> tuple[str, ...]:
"""Hvilke av kjernens dicts modellen bruker."""
if utv is not None:
bruker = getattr(utv, "BRUKER", ALLE_DICTS)
return tuple(d for d in bruker if hasattr(sim, d))
return DICTS_FOR_MODELL[modell]
def kjernedict(sim: ModuleType, navn: str) -> dict[str, object]:
"""Kopi av en av kjernens parameterdicts."""
return dict(getattr(sim, navn))
def overstyrbare(sim: ModuleType, navn: str) -> list[str]:
"""Nøkler i kjernens dict som kan overstyres under parametere (tall og sannhetsverdier)."""
return [k for k, v in kjernedict(sim, navn).items()
if (navn, k) not in SATT_AV_TILTAK and isinstance(v, (bool, int, float))]
def les_parametere(sim: ModuleType, raa: object, bruker: tuple[str, ...],
modell: str) -> dict[str, dict[str, object]]:
"""Valider parametere og lag fullt oppløste dicts."""
oppgitt = objekt(raa if raa is not None else {}, "parametere", list(ALLE_DICTS))
for navn in oppgitt:
if navn not in bruker:
feil(f"parametere.{navn}", f"brukes ikke av modellen {modell}. Denne modellen bruker: "
f"{', '.join(bruker)}.")
ut: dict[str, dict[str, object]] = {}
for navn in bruker:
d = kjernedict(sim, navn)
gyldige = overstyrbare(sim, navn)
endr = oppgitt.get(navn, {})
if not isinstance(endr, dict):
feil(f"parametere.{navn}", f"må være et objekt {{ ... }}, ikke {vis(endr)}.")
for nokkel, verdi in endr.items():
sti = f"parametere.{navn}.{nokkel}"
if (navn, nokkel) in SATT_AV_TILTAK:
feil(sti, f"settes med {SATT_AV_TILTAK[navn, nokkel]}, ikke under parametere.")
if nokkel not in gyldige:
feil(sti, f"ukjent parameter i {navn}.{lignende(nokkel, gyldige)} Gyldige: "
+ ", ".join(gyldige) + " (se docs/modellkart.md).")
d[nokkel] = parameterverdi(navn, nokkel, d[nokkel], verdi, sti)
ut[navn] = d
return ut
def parameterverdi(navn: str, nokkel: str, standard: object, verdi: object, sti: str) -> object:
"""Valider én parameter etter typen kjernen bruker."""
if navn == "CH2" and nokkel == "m" and verdi == "kalibrer":
return "kalibrer"
if isinstance(standard, bool):
return sannhet(verdi, sti)
if isinstance(standard, int):
nedre, ovre = HELTALLSGRENSER.get((navn, nokkel), (-10**9, 10**9))
return heltall(verdi, sti, nedre, ovre)
return tallverdi(verdi, sti, GRENSER.get((navn, nokkel), Grense()))
def les_styring(raa: object, modell: str, slutt: float) -> tuple[str, float, float, bool | None]:
"""Valider styring."""
s = objekt(raa if raa is not None else {}, "styring", STYRINGSFELT)
regulatorer = REGULATORER.get(modell, ("P", "MPC", "fast"))
regulator = tekst(s.get("regulator", "P"), "styring.regulator", regulatorer)
forsinkelse = tallverdi(s.get("forsinkelse", STANDARD_FORSINKELSE), "styring.forsinkelse",
Grense(0.0, ovre=slutt))
reaktivitet = tallverdi(s.get("reaktivitet", STANDARD_REAKTIVITET), "styring.reaktivitet",
Grense(0.0, ovre=100.0))
mpc: bool | None = None
if "mpc_kjenner_kjeden" in s:
if modell != "simulate_chain" or regulator != "MPC":
feil("styring.mpc_kjenner_kjeden", 'gjelder bare modell "simulate_chain" med regulator "MPC".')
mpc = sannhet(s["mpc_kjenner_kjeden"], "styring.mpc_kjenner_kjeden")
elif modell == "simulate_chain" and regulator == "MPC":
mpc = True
return regulator, forsinkelse, reaktivitet, mpc
def oppgitt_p(parametere: object) -> dict[str, object]:
"""parametere.P slik det står i fila (tomt hvis ikke oppgitt)."""
p = parametere.get("P", {}) if isinstance(parametere, dict) else {}
return p if isinstance(p, dict) else {}
def les_sjokk(raa: object, modell: str, p: dict[str, object], parametere: object) -> dict[str, float]:
"""Valider sjokk. Utenom simulate_soc settes P.shock og P.t_shock (varig sjokk)."""
s = objekt(raa if raa is not None else {}, "sjokk", SJOKKFELT)
if modell == "simulate_soc":
for nokkel in ("shock", "t_shock"):
if nokkel in oppgitt_p(parametere):
feil(f"parametere.P.{nokkel}", "brukes ikke av simulate_soc. Sjokket der er forbigående "
"og settes med sjokk.faktor, sjokk.fra og sjokk.til.")
faktor = tallverdi(s.get("faktor", STANDARD_SOC_SJOKK[0]), "sjokk.faktor", POSITIV)
fra = tallverdi(s.get("fra", STANDARD_SOC_SJOKK[1]), "sjokk.fra", IKKE_NEG)
til = tallverdi(s.get("til", STANDARD_SOC_SJOKK[2]), "sjokk.til", IKKE_NEG)
if til <= fra:
feil("sjokk.til", f"må være senere enn sjokk.fra ({tall_kort(fra)}).")
return {"faktor": faktor, "fra": fra, "til": til}
if "til" in s:
feil("sjokk.til", "gjelder bare simulate_soc. For et sjokk som går over i andre modeller: bruk "
'"modell": "utvidelse:paadrag_tidsserie" (docs/utvidelser.md).')
for felt, nokkel in (("faktor", "shock"), ("fra", "t_shock")):
if felt in s:
if nokkel in oppgitt_p(parametere):
feil(f"sjokk.{felt}", f"er oppgitt to ganger: også som parametere.P.{nokkel}. Bruk ett av dem.")
p[nokkel] = tallverdi(s[felt], f"sjokk.{felt}", GRENSER["P", nokkel])
return {"faktor": tallfelt(p, "shock"), "fra": tallfelt(p, "t_shock")}
def les_andel(raa: object) -> dict[str, object]:
"""tiltak.andel_utenom."""
a = objekt(raa, "tiltak.andel_utenom", ANDELFELT)
if "andel" not in a:
feil("tiltak.andel_utenom.andel", "mangler.")
return {"andel": tallverdi(a["andel"], "tiltak.andel_utenom.andel", Grense(0.0, ovre=1.0, ovre_med=False)),
"fra": tallverdi(a.get("fra", STANDARD_TILTAK_FRA), "tiltak.andel_utenom.fra", IKKE_NEG)}
def les_kapasitet(raa: object) -> dict[str, object]:
"""tiltak.kapasitet."""
k = objekt(raa, "tiltak.kapasitet", KAPASITETSFELT)
if "faktor" not in k:
feil("tiltak.kapasitet.faktor", "mangler.")
return {"faktor": tallverdi(k["faktor"], "tiltak.kapasitet.faktor", POSITIV),
"fra": tallverdi(k.get("fra", STANDARD_TILTAK_FRA), "tiltak.kapasitet.fra", IKKE_NEG),
"over": tallverdi(k.get("over", STANDARD_TILTAK_OVER), "tiltak.kapasitet.over", POSITIV)}
def les_tiltak(raa: object, modell: str, regel: str | None) -> dict[str, object]:
"""Valider tiltak (deklarativt; funksjonene bygges i bygg_tiltak)."""
t = objekt(raa if raa is not None else {}, "tiltak", TILTAKSFELT)
if not t:
return {}
tillatt = TILTAK_FOR_MODELL.get(modell, TILTAKSFELT)
for nokkel in t:
if nokkel not in tillatt:
hvilke = ", ".join(m for m in MODELLER if nokkel in TILTAK_FOR_MODELL[m])
feil(f"tiltak.{nokkel}", f"brukes ikke av modellen {modell}; bare av {hvilke}.")
if modell == "simulate_endo" and regel != "sched":
feil("tiltak", 'med simulate_endo gjelder faste tiltak bare regel "sched". De andre reglene '
"bestemmer andel utenom og kapasitet selv.")
ut: dict[str, object] = {}
if "andel_utenom" in t:
ut["andel_utenom"] = les_andel(t["andel_utenom"])
if "kapasitet" in t:
ut["kapasitet"] = les_kapasitet(t["kapasitet"])
if "trommel_buffer_tau" in t:
ut["trommel_buffer_tau"] = tallverdi(t["trommel_buffer_tau"], "tiltak.trommel_buffer_tau", POSITIV)
return ut
def xi_trinn(xi_ny: float, fra: float) -> Callable[[float], float]:
"""Andel til domstolen: 1 før fra, xi_ny fra og med fra (som xi_und i sim.py)."""
def xi(t: float) -> float:
return xi_ny if t >= fra else 1.0
return xi
def z_rampe(z0: float, faktor: float, fra: float, over: float) -> Callable[[float], float]:
"""Kapasitet økt lineært over en periode (som Z_utv i sim.py, samme regneuttrykk)."""
def z(t: float) -> float:
return float(z0 * (1 + (faktor - 1) * np.clip((t - fra) / over, 0, 1)))
return z
def bygg_tiltak(k: Kjoring, navn: str, d: dict[str, object]) -> dict[str, object]:
"""Kjernens kjededict med tiltakene som funksjoner (Zfun, xifun/xiBfun, dbr)."""
ch = dict(d)
andel = k.tiltak.get("andel_utenom")
kap = k.tiltak.get("kapasitet")
if isinstance(andel, dict):
a, fra = tallfelt(andel, "andel"), tallfelt(andel, "fra")
if navn == "CH2":
ch["xiBfun"] = xi_trinn(1.0 - a / (1.0 - tallfelt(ch, "piA")), fra)
else:
ch["xifun"] = xi_trinn(1.0 - a, fra)
if isinstance(kap, dict):
ch["Zfun"] = z_rampe(tallfelt(ch, "Z"), tallfelt(kap, "faktor"), tallfelt(kap, "fra"),
tallfelt(kap, "over"))
if "trommel_buffer_tau" in k.tiltak:
ch["dbr"] = tallfelt(k.tiltak, "trommel_buffer_tau")
return ch
def sjekk_ch0(k: Kjoring) -> None:
"""Tiltak i kjeden krever en endelig kapasitet."""
if math.isinf(tallfelt(k.dicts["CH0"], "Z")):
for nokkel in ("kapasitet", "trommel_buffer_tau"):
if nokkel in k.tiltak:
feil(f"tiltak.{nokkel}", "har ingen mening når parametere.CH0.Z er inf (ingen flaskehals).")
def sjekk_ch2(k: Kjoring, sim: ModuleType) -> None:
"""Andel utenom fra klasse B, og kalibrering av m."""
ch2 = k.dicts["CH2"]
andel = k.tiltak.get("andel_utenom")
rom = 1.0 - tallfelt(ch2, "piA")
if isinstance(andel, dict) and tallfelt(andel, "andel") > rom:
feil("tiltak.andel_utenom.andel", "i simulate_chain2 ledes sakene utenom fra klasse B; andelen av "
f"alle saker kan da være høyst 1 - piA = {tall_kort(rom)}.")
if ch2["m"] == "kalibrer":
try:
ch2["m"] = float(sim.calibrate_m(dict(ch2, m=None), k.dicts["P"]))
except ValueError:
ch2["m"] = None
if ch2["m"] is None:
feil("parametere.CH2.m", "kalibreringen fant ingen m som gir saksbehandlingstid 3 mnd i "
"utgangslikevekten med disse parametrene.")
def sjekk_endo(k: Kjoring) -> None:
"""Rekkefølger i ENDO."""
e = k.dicts["ENDO"]
if tallfelt(e, "L1") >= tallfelt(e, "L2"):
feil("parametere.ENDO.L1", "må være mindre enn L2 (kapasitet realiseres fra L1 til L2 mnd etter vedtak).")
if tallfelt(e, "ups_lo") >= tallfelt(e, "ups_hi"):
feil("parametere.ENDO.ups_lo", "må være mindre enn ups_hi (dødsonen).")
def sjekk_kombinasjoner(k: Kjoring, sim: ModuleType, utv: ModuleType | None) -> None:
"""Sammenhenger mellom felt som ikke kan sjekkes ett og ett."""
if utv is not None:
if hasattr(utv, "sjekk"):
try:
utv.sjekk(til_utvidelse(k))
except ValueError as e:
feil("", str(e))
return
if "CH0" in k.dicts:
sjekk_ch0(k)
if "CH2" in k.dicts:
sjekk_ch2(k, sim)
if "ENDO" in k.dicts:
sjekk_endo(k)
def les_utvidelse(raa: object, utv: ModuleType | None) -> dict[str, object]:
"""Valider utvidelsens parametere mot PARAMETERE (samme type som standardverdien)."""
if utv is None:
if raa is not None:
feil("utvidelse", 'brukes bare med "modell": "utvidelse:<navn>".')
return {}
standard: dict[str, object] = copy.deepcopy(utv.PARAMETERE)
oppgitt = objekt(raa if raa is not None else {}, "utvidelse", list(standard))
for nokkel, verdi in oppgitt.items():
sti = f"utvidelse.{nokkel}"
std = standard[nokkel]
if isinstance(std, bool):
standard[nokkel] = sannhet(verdi, sti)
elif isinstance(std, int):
standard[nokkel] = heltall(verdi, sti, -10**9, 10**9)
elif isinstance(std, float):
standard[nokkel] = tallverdi(verdi, sti, Grense(inf=True))
elif isinstance(std, str):
standard[nokkel] = tekst(verdi, sti)
elif isinstance(std, list) and not isinstance(verdi, list):
feil(sti, f"må være en liste [ ... ], ikke {vis(verdi)}.")
else:
standard[nokkel] = copy.deepcopy(verdi)
return standard
def les_regel(raa: dict[str, object], modell: str) -> str | None:
"""Regelen (bare simulate_endo)."""
if modell == "simulate_endo":
if "regel" not in raa:
feil("regel", "mangler. simulate_endo trenger en regel: " + ", ".join(REGLER) + ".")
return tekst(raa["regel"], "regel", REGLER)
if "regel" in raa:
feil("regel", "gjelder bare simulate_endo.")
return None
def los_opp(sim: ModuleType, raa: dict[str, object], modell: str, utv: ModuleType | None,
variant: Variant) -> Kjoring:
"""Valider én variant av scenariofila og lag en kjøring."""
objekt(raa, "", TOPPFELT)
slutt = tallverdi(raa.get("slutt", float(sim.TEND)), "slutt", Grense(0.0, nedre_med=False, ovre=MAKS_SLUTT))
regel = les_regel(raa, modell)
regulator, forsinkelse, reaktivitet, mpc = les_styring(raa.get("styring"), modell, slutt)
dicts = les_parametere(sim, raa.get("parametere"), parameterdicts(sim, modell, utv), modell)
sjokk: dict[str, float] = {}
if utv is not None and "sjokk" in raa:
feil("sjokk", 'brukes ikke med utvidelser. Se utvidelsens egne parametere under "utvidelse" '
"(docs/utvidelser.md).")
if utv is None:
sjokk = les_sjokk(raa.get("sjokk"), modell, dicts["P"], raa.get("parametere"))
k = Kjoring(etikett=variant.etikett, sveipverdier=variant.sveipverdier, modell=modell,
regulator=regulator, forsinkelse=forsinkelse, reaktivitet=reaktivitet,
mpc_kjenner_kjeden=mpc, regel=regel, slutt=slutt, sjokk=sjokk,
tiltak=les_tiltak(raa.get("tiltak"), modell, regel), dicts=dicts,
utvidelse=les_utvidelse(raa.get("utvidelse"), utv))
sjekk_kombinasjoner(k, sim, utv)
return k
def sveipsti(sti_: object, nr: int, utv: ModuleType | None, sim: ModuleType, modell: str) -> str:
"""Valider stien til en sveipet parameter."""
hvor = f"sveip[{nr}].parameter"
sti = tekst(sti_, hvor)
deler = sti.split(".")
if deler[0] not in SVEIPBARE:
feil(hvor, f'"{sti}" begynner ikke med et felt som kan sveipes.' + lignende(deler[0], SVEIPBARE)
+ " Gyldige: " + ", ".join(SVEIPBARE) + ".")
gyldige: dict[str, tuple[str, ...]] = {
"styring": STYRINGSFELT, "sjokk": SJOKKFELT, "tiltak": TILTAKSFELT,
"tiltak.andel_utenom": ANDELFELT, "tiltak.kapasitet": KAPASITETSFELT,
"parametere": parameterdicts(sim, modell, utv),
}
for navn in ALLE_DICTS:
if hasattr(sim, navn):
gyldige[f"parametere.{navn}"] = tuple(overstyrbare(sim, navn))
if utv is not None:
gyldige["utvidelse"] = tuple(utv.PARAMETERE)
for i in range(1, len(deler)):
forelder, barn = ".".join(deler[:i]), deler[i]
valg = gyldige.get(forelder)
if valg is None:
feil(hvor, f'"{sti}": {forelder} har ingen underfelt.')
if barn not in valg:
feil(hvor, f'"{sti}": ukjent felt "{barn}" under {forelder}.' + lignende(barn, valg)
+ " Gyldige: " + ", ".join(valg) + ".")
if sti in gyldige:
feil(hvor, f'"{sti}" er en gruppe, ikke én verdi. Velg ett av: '
+ ", ".join(f"{sti}.{v}" for v in gyldige[sti]) + ".")
return sti
def sett_sti(raa: dict[str, object], sti: str, verdi: object) -> None:
"""Sett en verdi i scenariofila etter en punktum-sti, og lag objektene som mangler."""
deler = sti.split(".")
node = raa
for d in deler[:-1]:
neste = node.get(d)
if not isinstance(neste, dict):
neste = {}
node[d] = neste
node = neste
if verdi is None: # null i et sveip: kjernens verdi (feltet fjernes)
node.pop(deler[-1], None)
else:
node[deler[-1]] = copy.deepcopy(verdi)
def les_sveip(raa: object, utv: ModuleType | None, sim: ModuleType,
modell: str) -> list[tuple[str, list[object]]]:
"""Valider sveip: ett objekt eller en liste med ett eller to."""
if raa is None:
return []
liste = raa if isinstance(raa, list) else [raa]
if not 1 <= len(liste) <= RUTENETT:
feil("sveip", f"kan ha én eller to parametere (et rutenett), ikke {len(liste)}.")
ut: list[tuple[str, list[object]]] = []
for nr, s in enumerate(liste):
obj = objekt(s, f"sveip[{nr}]", SVEIPFELT)
if "parameter" not in obj or "verdier" not in obj:
feil(f"sveip[{nr}]", 'trenger både "parameter" og "verdier".')
sti = sveipsti(obj["parameter"], nr, utv, sim, modell)
verdier = obj["verdier"]
if not isinstance(verdier, list) or not verdier:
feil(f"sveip[{nr}].verdier", f"må være en liste med minst én verdi, ikke {vis(verdier)}.")
etiketter = [repr(float(v)) if isinstance(v, (int, float)) and not isinstance(v, bool) else filverdi(v)
for v in verdier]
if len(set(etiketter)) != len(etiketter):
feil(f"sveip[{nr}].verdier", "har samme verdi to ganger.")
ut.append((sti, list(verdier)))
if len(ut) == RUTENETT and ut[0][0] == ut[1][0]:
feil("sveip[1].parameter", "er samme parameter som sveip[0].")
antall = math.prod(len(v) for _, v in ut)
if antall > MAKS_KJORINGER:
feil("sveip", f"gir {antall} kjøringer; høyst {MAKS_KJORINGER} er tillatt.")
return ut
def les_utdata(raa: object) -> tuple[str, list[str] | None, int, str | None]:
"""Valider utdata."""
u = objekt(raa if raa is not None else {}, "utdata", UTDATAFELT)
fmt = tekst(u.get("format", "csv"), "utdata.format", FORMATER)
kolonner: list[str] | None = None
if "kolonner" in u:
k = u["kolonner"]
if not isinstance(k, list) or not k or not all(isinstance(x, str) for x in k):
feil("utdata.kolonner", f"må være en liste med navn på tidsserier, ikke {vis(k)}.")
kolonner = [str(x) for x in k]
if len(set(kolonner)) != len(kolonner):
feil("utdata.kolonner", "har samme kolonne to ganger.")
hvert = heltall(u.get("hvert", NOTAT_HVERT if fmt == "notat" else CSV_HVERT), "utdata.hvert", 1, 10**6)
overskrift: str | None = None
if "overskrift" in u:
if fmt != "notat":
feil("utdata.overskrift", 'gjelder bare "format": "notat" (CSV-fila bruker kolonnenavnene).')
overskrift = tekst(u["overskrift"], "utdata.overskrift")
if kolonner is None or len(overskrift.split()) != len(kolonner):
feil("utdata.overskrift", "må ha like mange ord som utdata.kolonner har navn.")
return fmt, kolonner, hvert, overskrift
def valgte_stier(raa: dict[str, object], sveip: list[tuple[str, list[object]]]) -> list[str]:
"""Bladstiene brukeren har satt (til linjen om hva som er overstyrt)."""
ut: list[str] = []
def besok(node: object, sti: str) -> None:
if isinstance(node, dict) and node:
for k, v in node.items():
besok(v, f"{sti}.{k}")
elif sti not in ut:
ut.append(sti)
for felt in SVEIPBARE:
if felt in raa:
besok(raa[felt], felt)
for sti, _ in sveip:
if sti not in ut:
ut.append(sti)
return ut
def les_scenario(fil: Path) -> Scenario:
"""Les, valider og løs opp scenariofila til en liste kjøringer."""
raa = les_fil(fil)
objekt(raa, "", TOPPFELT)
if "modell" not in raa:
feil("modell", "mangler. Gyldige: " + ", ".join(MODELLER) + ", utvidelse:<navn>.")
modell = modellnavn(raa["modell"])
sim = last_kjerne()
utv = last_utvidelse(modell[len(UTVIDELSE_PREFIKS):]) if modell.startswith(UTVIDELSE_PREFIKS) else None
navn = tekst(raa.get("navn", fil.stem), "navn")
if not navn.strip():
feil("navn", "kan ikke være tom.")
beskrivelse = tekst(raa.get("beskrivelse", ""), "beskrivelse")
if "merknad" in raa:
tekst(raa["merknad"], "merknad")
fmt, kolonner, hvert, overskrift = les_utdata(raa.get("utdata"))
sveip = les_sveip(raa.get("sveip"), utv, sim, modell)
grunn = {k: v for k, v in raa.items() if k not in {"sveip", "utdata", "navn", "beskrivelse", "merknad"}}
kjoringer: list[Kjoring] = []
if not sveip:
kjoringer.append(los_opp(sim, grunn, modell, utv, Variant(slug(navn), [])))
for kombinasjon in itertools.product(*(v for _, v in sveip)) if sveip else ():
variant = copy.deepcopy(grunn)
verdier = [(sti, verdi) for (sti, _), verdi in zip(sveip, kombinasjon)]
for sti, verdi in verdier:
sett_sti(variant, sti, verdi)
etikett = "_".join(f"{sti.rsplit('.', 1)[-1]}={filverdi(v)}" for sti, v in verdier)
kjoringer.append(los_opp_i_sveip(sim, variant, modell, utv, Variant(etikett, verdier)))
return Scenario(navn=navn, beskrivelse=beskrivelse, modell=modell, format=fmt, kolonner=kolonner,
hvert=hvert, overskrift=overskrift, sveip=sveip, valgt=valgte_stier(raa, sveip),
kjoringer=kjoringer, utvidelse=utv)
def los_opp_i_sveip(sim: ModuleType, raa: dict[str, object], modell: str, utv: ModuleType | None,
variant: Variant) -> Kjoring:
"""Som los_opp, men feilmeldingen sier hvilken kombinasjon i sveipet som feilet."""
try:
return los_opp(sim, raa, modell, utv, variant)
except FeltFeil as e:
hvor = ", ".join(f"{sti} = {vis(v)}" for sti, v in variant.sveipverdier)
melding = f"{e} (i sveipet: {hvor})"
raise FeltFeil(melding) from None
# --------------------------------------------------------------------------- simulering
@dataclass
class Resultat:
"""Tidsserier, skalarer og vedtakslogg fra én kjøring."""
serier: dict[str, Arr]
skalarer: dict[str, float]
logg: list[str]
def til_utvidelse(k: Kjoring) -> dict[str, object]:
"""Scenarioet slik en utvidelse får det (docs/utvidelser.md)."""
dicts: dict[str, object] = {}
for navn, d in k.dicts.items():
dicts[navn] = bygg_tiltak(k, navn, d) if navn in {"CH0", "CH2"} else dict(d)
return {
"navn": k.etikett,
"styring": {"regulator": k.regulator, "forsinkelse": k.forsinkelse, "reaktivitet": k.reaktivitet},
"slutt": k.slutt,
"tiltak": copy.deepcopy(k.tiltak),
"parametere": dicts,
"utvidelse": copy.deepcopy(k.utvidelse),
}
def fra_tuppel(r: tuple[Arr, Arr, Arr, Arr, Arr]) -> dict[str, object]:
"""(t, K, U, L, T) fra simulate og simulate_soc."""
return dict(zip(("t", "K", "U", "L", "T"), r))
def kjor_endo(sim: ModuleType, k: Kjoring) -> dict[str, object]:
"""simulate_endo; tiltakene blir tidsplanene xi_sched og Z_sched (regel "sched")."""
ch = bygg_tiltak(k, "CH0", k.dicts["CH0"])
xs, zs = ch.get("xifun"), ch.get("Zfun")
ch.update(xifun=None, Zfun=None)
raa = dict(sim.simulate_endo(k.regel, tau=k.forsinkelse, p=k.dicts["P"], ch=ch, e=k.dicts["ENDO"],
kp=k.reaktivitet, tend=k.slutt, xi_sched=xs, Z_sched=zs))
raa["Z_rel"] = raa["Z"] / ch["Z"]
return raa
def kjor(sim: ModuleType, k: Kjoring, utv: ModuleType | None) -> Resultat:
"""Kjør én simulering med kjernens funksjoner."""
p = k.dicts["P"]
tau, kp = k.forsinkelse, k.reaktivitet
with horisont(sim, k.slutt), np.errstate(all="ignore"):
if utv is not None:
raa: dict[str, object] = dict(utv.simuler(til_utvidelse(k)))
elif k.modell == "simulate":
raa = fra_tuppel(sim.simulate(k.regulator, tau, p=p, kp=kp))
elif k.modell == "simulate_chain":
ch = bygg_tiltak(k, "CH0", k.dicts["CH0"])
raa = dict(sim.simulate_chain(k.regulator, tau, ch, p=p, kp=kp,
mpc_knows_chain=bool(k.mpc_kjenner_kjeden)))
avledet_kjede(sim, ch, raa)
elif k.modell == "simulate_chain2":
raa = dict(sim.simulate_chain2(tau, bygg_tiltak(k, "CH2", k.dicts["CH2"]), p=p, kp=kp))
elif k.modell == "simulate_soc":
raa = fra_tuppel(sim.simulate_soc(k.regulator, tau, p, s=k.dicts["SOC"], kp=kp,
shock=k.sjokk["faktor"], t_on=k.sjokk["fra"],
t_off=k.sjokk["til"]))
else:
raa = kjor_endo(sim, k)
return del_opp(raa, k)
def avledet_kjede(sim: ModuleType, ch: dict[str, object], raa: dict[str, object]) -> None:
"""Kapasitet og andel til domstolen over tid i simulate_chain (fra chain_at i kjernen)."""
t = raa["t"]
if not isinstance(t, np.ndarray):
return
par = [sim.chain_at(ch, float(x)) for x in t]
z = np.array([float(a) for a, _ in par])
raa["xi"] = np.array([float(b) for _, b in par])
if np.all(np.isfinite(z)):
raa["Z"] = z
raa["Z_rel"] = z / tallfelt(ch, "Z")
def del_opp(raa: dict[str, object], k: Kjoring) -> Resultat:
"""Skill tidsserier, skalarer og logg."""
t = raa.get("t")
if not isinstance(t, np.ndarray):
melding = f'Kjøringen {k.etikett} ga ingen tidsserie "t".'
raise ScenarioFeil(melding)
serier: dict[str, Arr] = {}
skalarer: dict[str, float] = {}
logg: list[str] = []
for navn, v in raa.items():
if isinstance(v, np.ndarray) and v.shape == t.shape:
serier[navn] = v.astype(np.float64, copy=False)
elif isinstance(v, (int, float, np.floating)) and not isinstance(v, bool):
skalarer[navn] = float(v)
elif navn == "log" and isinstance(v, list):
logg = [str(x) for x in v]
return Resultat(serier=serier, skalarer=skalarer, logg=logg)
def kjor_trygt(sim: ModuleType, k: Kjoring, utv: ModuleType | None) -> Resultat:
"""Som kjor, men regnefeil i kjernen blir en norsk melding (exit-kode 2)."""
try:
return kjor(sim, k, utv)
except (ArithmeticError, ValueError) as e:
hvem = f" ({kolonneoverskrift(k, 1)})" if k.sveipverdier else ""
melding = (f"Kjøringen{hvem} stoppet i kjernen: {e}. Parameterkombinasjonen gir trolig ingen "
"likevekt ved start eller en løsning som vokser over alle grenser.")
raise ScenarioFeil(melding) from None
# --------------------------------------------------------------------------- nøkkeltall
class Tall(NamedTuple):
"""Ett nøkkeltall."""
nokkel: str
tekst: str
verdi: float | None
desimaler: int
class Nokkeltall:
"""Samler nøkkeltallene til én kjøring."""
def __init__(self) -> None:
"""Tom liste."""
self.tall: list[Tall] = []
def legg(self, nokkel: str, tekst_: str, verdi: float | None, d: int) -> None:
"""Legg til ett nøkkeltall."""
self.tall.append(Tall(nokkel, tekst_, None if verdi is None else float(verdi), d))
def tall_krim(n: Nokkeltall, s: dict[str, Arr], endo: bool) -> None: # noqa: FBT001 - intern hjelper
"""Kriminalitet K (summary, summary_endo)."""
t, k_ = s["t"], s["K"]
if endo and np.any(t >= SNITT_FRA):
n.legg("K_snitt", "Kriminalitet K, gjennomsnitt fra mnd 12", k_[t >= SNITT_FRA].mean(), 3)
n.legg("K_maks", "Kriminalitet K, høyeste", k_.max(), 3)
n.legg("K_slutt", "Kriminalitet K, ved slutt", k_[-1], 3)
sen = t > SEN_FASE
n.legg("spenn_K", "Spenn i K etter mnd 60 (høyeste minus laveste)",
k_[sen].max() - k_[sen].min() if np.any(sen) else None, 3)
if t[-1] > 2 * SEN_FASE:
sist = t > t[-1] - SEN_FASE
n.legg("spenn_K_sist", "Spenn i K de siste 60 mnd (vedvarende svingning)",
k_[sist].max() - k_[sist].min(), 3)
def tall_innsats_tillit(n: Nokkeltall, s: dict[str, Arr]) -> None:
"""Belastning per klasse, innsats T og tillit L."""
for navn, tekst_ in (("KA", "Belastning klasse A (alvorlige), ved slutt"),
("KB", "Belastning klasse B (øvrige), ved slutt")):
if navn in s:
n.legg(f"{navn}_slutt", tekst_, s[navn][-1], 3)
if "T" in s:
n.legg("T_maks", "Politiets innsats T, høyeste", s["T"].max(), 2)
n.legg("T_slutt", "Politiets innsats T, ved slutt", s["T"][-1], 2)
if "L" in s:
n.legg("L_min", "Tillit, laveste", s["L"].min(), 3)
n.legg("L_slutt", "Tillit, ved slutt", s["L"][-1], 3)
def tall_kjede(n: Nokkeltall, s: dict[str, Arr], endo: bool) -> None: # noqa: FBT001 - intern hjelper
"""Saksbehandlingstid, kø og strøm i kjeden (summary, summary2)."""
for navn, tekst_ in (("tauD", "Saksbehandlingstid i domstolen"), ("tauA", "Saksbehandlingstid klasse A"),
("tauB", "Saksbehandlingstid klasse B")):
if navn in s:
n.legg(f"{navn}_maks", f"{tekst_}, lengste (mnd)", s[navn].max(), 1)
n.legg(f"{navn}_slutt", f"{tekst_}, ved slutt (mnd)", s[navn][-1], 1)
for navn in ("Q", "QA", "QB"):
if navn in s:
n.legg(f"{navn}_slutt", f"Kø {navn}, ved slutt", s[navn][-1], 2)
if "JD" in s and "RD" in s and not endo:
rd = float(s["RD"][-1])
n.legg("inn_ut_slutt", "Saker inn / avgjort, ved slutt", float(s["JD"][-1]) / rd if rd > 0 else None, 2)
def tall_tiltak(n: Nokkeltall, s: dict[str, Arr]) -> None:
"""Kapasitet, andel utenom og kvalitet."""
if "Z_rel" in s and np.ptp(s["Z_rel"]) > 0:
n.legg("Z_maks", "Kapasitet, høyeste (1,00 = ved start)", s["Z_rel"].max(), 2)
n.legg("Z_slutt", "Kapasitet, ved slutt (1,00 = ved start)", s["Z_rel"][-1], 2)
if "xi" in s and np.ptp(s["xi"]) > 0:
n.legg("utenom_maks_pst", "Andel ledet utenom, høyeste (%)", (1.0 - s["xi"].min()) * 100, 0)
n.legg("utenom_slutt_pst", "Andel ledet utenom, ved slutt (%)", (1.0 - s["xi"][-1]) * 100, 0)
if "q" in s and np.any(s["q"] != 0):
n.legg("q_maks", "Prosessuell kvalitet q, høyeste", s["q"].max(), 3)
def tall_endo(n: Nokkeltall, s: dict[str, Arr], ch: dict[str, object], p: dict[str, object]) -> None:
"""Frafall og kapasitetsår (summary_endo; frafallet i måneder av innstrømningen, som i notatets tabell)."""
t = s["t"]
z0 = tallfelt(ch, "Z")
j0 = tallfelt(p, "gamma") * tallfelt(p, "Lam0") * tallfelt(p, "T0") # J0 i sim.py
n.legg("frafall", "Frafall fra køen, i måneder av innstrømningen før sjokket",
float(np.trapezoid(tallfelt(ch, "varsigma") * s["Q"], t)) / j0 if j0 > 0 else None, 1)
n.legg("kapasitetsaar", "Kapasitetsår over utgangsnivået",
float(np.trapezoid(np.maximum(s["Z"] - z0, 0) / z0, t) / 12), 2)
def nokkeltall(r: Resultat, k: Kjoring) -> list[Tall]:
"""Nøkkeltallene kjernens summary, summary2 og summary_endo skriver ut, for én kjøring."""
s = r.serier
n = Nokkeltall()
endo = k.modell == "simulate_endo"
if "K" in s:
tall_krim(n, s, endo)
tall_innsats_tillit(n, s)
tall_kjede(n, s, endo)
tall_tiltak(n, s)
if endo and "Z" in s and "Q" in s:
tall_endo(n, s, k.dicts["CH0"], k.dicts["P"])
return n.tall
# --------------------------------------------------------------------------- utdata
@dataclass
class Utfall:
"""Alt som trengs for å skrive sammendraget og resultat.json."""
sc: Scenario
fil: Path
alle: list[list[Tall]]
resultater: list[Resultat]
filer: list[tuple[str, list[str]]]
dt: float
def kolonner_for(sc: Scenario, r: Resultat) -> list[str]:
"""Kolonnene som skrives for en kjøring."""
if sc.kolonner is None:
return list(r.serier)
mangler = [c for c in sc.kolonner if c not in r.serier]
if mangler:
feil("utdata.kolonner", f'"{mangler[0]}" finnes ikke for denne modellen.'
+ lignende(mangler[0], list(r.serier)) + " Tilgjengelige: " + ", ".join(r.serier) + ".")
return sc.kolonner
def skriv_csv(sim: ModuleType, sc: Scenario, r: Resultat, sti: Path) -> list[str]:
"""Skriv tidsseriene. "notat": kjernens write_csv (mellomrom, %.5f); "csv": komma og .10g."""
kolonner = kolonner_for(sc, r)
cols = [r.serier[c] for c in kolonner]
if sc.format == "notat":
sim.write_csv(str(sti), cols, sc.overskrift or " ".join(kolonner), every=sc.hvert)
return kolonner
tabell_ = np.column_stack(cols)[::sc.hvert]
linjer = [",".join(kolonner)]
linjer.extend(",".join(format(float(x), ".10g") for x in rad) for rad in tabell_)
sti.write_text("\n".join(linjer) + "\n", encoding="utf-8")
return kolonner
def filnavn(sc: Scenario, k: Kjoring) -> str:
"""CSV-fila til en kjøring."""
endelse = ".dat" if sc.format == "notat" else ".csv"
return (k.etikett if not sc.sveip else f"{slug(sc.navn)}__{k.etikett}") + endelse
def kolonneoverskrift(k: Kjoring, antall_sveip: int) -> str:
"""Kort navn på en kjøring i tabellen: «navn=verdi» for ett sveip, verdiene for et rutenett."""
if not k.sveipverdier:
return "verdi"
if antall_sveip == 1:
sti, v = k.sveipverdier[0]
return f"{sti.rsplit('.', 1)[-1]}={vis_verdi(v)}"
return " / ".join(vis_verdi(v) for _, v in k.sveipverdier)
def tabell(rader: list[list[str]]) -> list[str]:
"""Markdown-tabell med kolonner som står rett også i terminalen (tall høyrejustert)."""
bredde = [max(len(rad[i]) for rad in rader) for i in range(len(rader[0]))]
ut = []
for nr, rad in enumerate(rader):
celler = [c.ljust(bredde[i]) if i == 0 else c.rjust(bredde[i]) for i, c in enumerate(rad)]
ut.append("| " + " | ".join(celler) + " |")
if nr == 0:
ut.append("|" + "|".join("-" * (b + 2) if i == 0 else "-" * (b + 1) + ":"
for i, b in enumerate(bredde)) + "|")
return ut
def nokkeltabeller(sc: Scenario, alle: list[list[Tall]]) -> list[str]:
"""Nøkkeltall: én rad per nøkkeltall, én kolonne per kjøring (høyst seks per tabell)."""
ut: list[str] = []
nokler: dict[str, tuple[str, int]] = {}
for tallene in alle:
for x in tallene:
nokler.setdefault(x.nokkel, (x.tekst, x.desimaler))
oppslag = [{x.nokkel: x for x in tallene} for tallene in alle]
for start in range(0, len(sc.kjoringer), MAKS_KOLONNER_I_TABELL):
del_ = range(start, min(start + MAKS_KOLONNER_I_TABELL, len(sc.kjoringer)))
rader = [["Nøkkeltall", *(kolonneoverskrift(sc.kjoringer[i], len(sc.sveip)) for i in del_)]]
for nokkel, (tekst_, d) in nokler.items():
rader.append([tekst_, *(tall(oppslag[i][nokkel].verdi, d) if nokkel in oppslag[i] else "-"
for i in del_)])
ut.extend(tabell(rader))
ut.append("")
return ut
def matrise(sc: Scenario, alle: list[list[Tall]]) -> list[str]:
"""For et rutenett: ett nøkkeltall som tabell med den første parameteren i rader."""
(sti1, v1), (sti2, v2) = sc.sveip
oppslag = [{x.nokkel: x for x in tallene} for tallene in alle]
nokkel = next((n for n in ("spenn_K_sist", "spenn_K", "K_slutt") if n in oppslag[0]), "")
if nokkel not in oppslag[0]:
return []
x0 = oppslag[0][nokkel]
rader = [[f"{sti1.rsplit('.', 1)[-1]} / {sti2.rsplit('.', 1)[-1]}", *(vis_verdi(v) for v in v2)]]
for i, a in enumerate(v1):
rad = [vis_verdi(a)]
for j in range(len(v2)):
x = oppslag[i * len(v2) + j].get(nokkel)
rad.append(tall(x.verdi if x else None, x0.desimaler))
rader.append(rad)
return [f"{x0.tekst}; rader: {sti1}, kolonner: {sti2}", "", *tabell(rader), ""]
def styringstekst(k: Kjoring) -> str:
"""Kort tekst om styringen."""
tekst_ = REGULATORTEKST.get(k.regulator, k.regulator)
if k.regulator == "fast":
return tekst_
tekst_ += f", forsinkelse {tall_kort(k.forsinkelse)} mnd, reaktivitet {tall_kort(k.reaktivitet)}"
if k.mpc_kjenner_kjeden is not None:
tekst_ += ", MPC kjenner kjeden" if k.mpc_kjenner_kjeden else ", MPC uten kjedemodell"
return tekst_
def hode(u: Utfall) -> list[str]:
"""Overskrift, modell, styring og linjen om hva som er notatets parametere."""
sc = u.sc
k0 = sc.kjoringer[0]
modelltekst = sc.utvidelse.BESKRIVELSE if sc.utvidelse is not None else MODELLTEKST.get(sc.modell, "")
linjer = [f"# Scenario: {sc.navn}", ""]
if sc.beskrivelse:
linjer += [sc.beskrivelse, ""]
linjer.append(f"- Scenariofil: {u.fil.name}")
linjer.append(f"- Modell: {sc.modell} - {modelltekst}")
sveipet = {sti.split(".", 1)[0] for sti, _ in sc.sveip}
merk = " (første kjøring; se sveipet)"
if k0.regel:
linjer.append(f"- Regel: {k0.regel}" + (merk if "regel" in sveipet else ""))
linjer.append(f"- Styring: {styringstekst(k0)}" + (merk if "styring" in sveipet else ""))
linjer.append(f"- Horisont: {tall_kort(k0.slutt)} mnd" + (merk if "slutt" in sveipet else ""))
linjer.extend(f"- Sveip: {sti} = " + "; ".join(vis_verdi(v) for v in verdier) for sti, verdier in sc.sveip)
if len(sc.sveip) == RUTENETT:
linjer.append(f"- Kolonnene i tabellen er {sc.sveip[0][0]} / {sc.sveip[1][0]}.")
valgt = ", ".join(sc.valgt) if sc.valgt else "ingenting"
linjer += ["", ("Parametrene er notatets stiliserte verdier (tabell 3 i notatet), ikke kalibrert på norske "
f"data, unntatt det scenariofila setter selv: {valgt}. Tallene viser modellens dynamikk, "
"ikke prognoser."), ""]
return linjer
def vedtakslinjer(u: Utfall) -> list[str]:
"""Budsjettvedtakene fra simulate_endo (loggen i kjernen)."""
logger = [(k, r.logg) for k, r in zip(u.sc.kjoringer, u.resultater) if r.logg]
if not logger:
return []
linjer = ["", "## Budsjettvedtak (fra regelen i simulate_endo)"]
for k, logg in logger:
linjer.append("")
if u.sc.sveip:
linjer += [f"{kolonneoverskrift(k, len(u.sc.sveip))}:", ""]
linjer.extend(f"- {x}" for x in logg)
return linjer
def sammendrag(u: Utfall) -> str:
"""Teksten i sammendrag.md (og på skjermen)."""
linjer = [*hode(u), "## Nøkkeltall", "", *nokkeltabeller(u.sc, u.alle)]
if len(u.sc.sveip) == RUTENETT:
linjer += matrise(u.sc, u.alle)
linjer.append("Slik leses tallene: 1,00 er nivået i referanselikevekten før sjokket for kriminaliteten K "
"og politiets innsats T; tilliten er 0,80 der. Kapasitet: 1,00 er kapasiteten ved start. "
"Tider i måneder.")
linjer += vedtakslinjer(u)
linjer += ["", "## Filer", ""]
linjer.extend(f"- {navn}: {', '.join(kolonner)}" for navn, kolonner in u.filer)
linjer.append(f"- Hvert {u.sc.hvert}. tidssteg ({tall(u.sc.hvert * u.dt, 2)} mnd). Alle parametere slik de "
"ble brukt, versjoner og sjekksummer står i resultat.json.")
return "\n".join(linjer) + "\n"
def kjoringsdata(sim: ModuleType, k: Kjoring) -> dict[str, object]:
"""Alle parametere slik simulatoren fikk dem (til resultat.json)."""
styring: dict[str, object] = {"regulator": k.regulator, "forsinkelse": k.forsinkelse,
"reaktivitet": k.reaktivitet}
if k.mpc_kjenner_kjeden is not None:
styring["mpc_kjenner_kjeden"] = k.mpc_kjenner_kjeden
ut: dict[str, object] = {"modell": k.modell, "styring": styring, "slutt": k.slutt, "DT": float(sim.DT)}
if k.regel is not None:
ut["regel"] = k.regel
if k.sjokk:
ut["sjokk"] = k.sjokk
if k.tiltak:
ut["tiltak"] = k.tiltak
ut["parametere"] = {navn: {n: v for n, v in d.items() if (navn, n) not in SATT_AV_TILTAK}
for navn, d in k.dicts.items()}
if k.utvidelse:
ut["utvidelse"] = k.utvidelse
return ut
def resultatjson(sim: ModuleType, u: Utfall) -> str:
"""Innholdet i resultat.json."""
sc = u.sc
kjoringer = []
for k, tallene, r, (navn, kolonner) in zip(sc.kjoringer, u.alle, u.resultater, u.filer):
post: dict[str, object] = {
"navn": k.etikett,
"sveip": dict(k.sveipverdier),
"fil": navn,
"kolonner": kolonner,
"brukt": kjoringsdata(sim, k),
"nokkeltall": {x.nokkel: x.verdi for x in tallene},
}
if r.skalarer:
post["skalarer"] = r.skalarer
if r.logg:
post["vedtak"] = r.logg
kjoringer.append(post)
forklaring: dict[str, str] = {}
for tallene in u.alle:
for x in tallene:
forklaring.setdefault(x.nokkel, x.tekst)
data: dict[str, object] = {
"scenario": sc.navn,
"beskrivelse": sc.beskrivelse,
"scenariofil": u.fil.name,
"scenariofil_sha256": sha256(u.fil),
"modell": sc.modell,
"utdata": {"format": sc.format, "hvert": sc.hvert},
"versjoner": {"python": sys.version.split()[0], "numpy": np.__version__,
"scipy": importlib.import_module("scipy").__version__},
"kjerne_sha256": {n: sha256(KJERNE / n) for n in KJERNEFILER if (KJERNE / n).exists()},
}
if sc.utvidelse is not None:
data["utvidelse_sha256"] = sha256(UTVIDELSER / f"{sc.modell[len(UTVIDELSE_PREFIKS):]}.py")
data["merknad"] = ("Parametrene er notatets stiliserte verdier, unntatt det scenariofila setter selv. "
"Tallene viser modellens dynamikk, ikke prognoser.")
data["nokkeltall_forklaring"] = forklaring
data["kjoringer"] = kjoringer
return json.dumps(json_verdi(data), ensure_ascii=False, indent=2) + "\n"
def klargjor_mappe(ut: Path) -> None:
"""Lag mappa; fjern bare filer et tidligere resultat i samme mappe har laget."""
if ut.exists() and not ut.is_dir():
melding = f"--ut: {ut} finnes og er ikke en mappe."
raise ScenarioFeil(melding)
if ut.exists() and any(ut.iterdir()):
gammel = ut / "resultat.json"
if not gammel.is_file():
melding = (f"--ut: mappa {ut} finnes og har filer som ikke er et tidligere resultat. Velg en "
"annen mappe med --ut.")
raise ScenarioFeil(melding)
try:
data = json.loads(gammel.read_text(encoding="utf-8"))
filer = [str(x["fil"]) for x in data.get("kjoringer", [])]
except (OSError, ValueError, TypeError, KeyError, AttributeError):
filer = []
for navn in [*filer, "sammendrag.md", "resultat.json"]:
sti = ut / navn
if sti.parent == ut and sti.is_file():
sti.unlink()
ut.mkdir(parents=True, exist_ok=True)
# --------------------------------------------------------------------------- inngang
BRUK = ("Bruk: python3 modell/kjor.py scenario <fil.json> [--ut <mappe>]\n"
"Formatet er beskrevet i docs/scenarioformat.md.")
def tolk(argv: list[str]) -> tuple[Path, Path | None] | None:
"""Les argumentene; None betyr at bruken skal skrives ut."""
fil: Path | None = None
ut: Path | None = None
i = 0
while i < len(argv):
a = argv[i]
if a in {"-h", "--hjelp", "--help"}:
return None
if a == "--ut":
if i + 1 >= len(argv):
raise ScenarioFeil("--ut trenger en mappe.\n" + BRUK)
ut = Path(argv[i + 1])
i += 2
continue
if a.startswith("--ut="):
ut = Path(a[len("--ut="):])
elif a.startswith("-"):
raise ScenarioFeil(f"Ukjent valg {a}.\n" + BRUK)
elif fil is None:
fil = Path(a)
else:
raise ScenarioFeil(f"Bare én scenariofil om gangen (fikk også {a}).\n" + BRUK)
i += 1
if fil is None:
raise ScenarioFeil("Mangler scenariofil.\n" + BRUK)
return fil, ut
def kjor_scenario(fil: Path, ut: Path | None) -> tuple[str, Path]:
"""Kjør hele scenariofila og skriv filene. Returnerer sammendraget og mappa."""
sc = les_scenario(fil)
sim = last_kjerne()
mappe = ut if ut is not None else Path.cwd() / "resultater" / slug(sc.navn)
resultater = [kjor_trygt(sim, k, sc.utvidelse) for k in sc.kjoringer]
alle = [nokkeltall(r, k) for r, k in zip(resultater, sc.kjoringer)]
for r in resultater:
kolonner_for(sc, r)
klargjor_mappe(mappe)
filer: list[tuple[str, list[str]]] = []
for k, r in zip(sc.kjoringer, resultater):
navn = filnavn(sc, k)
filer.append((navn, skriv_csv(sim, sc, r, mappe / navn)))
u = Utfall(sc=sc, fil=fil, alle=alle, resultater=resultater, filer=filer, dt=float(sim.DT))
tekst_ = sammendrag(u)
(mappe / "sammendrag.md").write_text(tekst_, encoding="utf-8")
(mappe / "resultat.json").write_text(resultatjson(sim, u), encoding="utf-8")
return tekst_, mappe
def main(argv: list[str]) -> int:
"""Kommandoen scenario. Exit-kode 0 ok, 2 feil i scenariofila eller bruken."""
fil_tekst = ""
try:
tolket = tolk(argv)
if tolket is None:
sys.stdout.write(BRUK + "\n")
return 0
fil, ut = tolket
fil_tekst = str(fil)
tekst_, mappe = kjor_scenario(fil, ut)
except ScenarioFeil as e:
hvor = f"Feil i scenariofila {fil_tekst}: " if isinstance(e, FeltFeil) else ""
sys.stdout.flush()
sys.stderr.write(f"{hvor}{e}\n")
return 2
sys.stdout.write(tekst_)
sys.stdout.write(f"\nResultatene ligger i {mappe}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))