feat(scenario): scenario runner, extensions and working copy
scenario.py runs all five simulators of the core from a JSON scenario file, with sweeps, and reproduces 20 of the note's data files (14 byte for byte, 6 column-wise). Extensions live outside the core and must reproduce it exactly in a limit case; paadrag_tidsserie is the worked example. arbeidskopi.py gives the user a copy of the model they can change. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
parent
464ad38b49
commit
03151cfb3b
36 changed files with 3580 additions and 5 deletions
102
tests/test_arbeidskopi.py
Normal file
102
tests/test_arbeidskopi.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# Copyright (c) 2026 Kjell Tore Guttormsen. MIT License, see LICENSE.
|
||||
"""arbeidskopi.py: kopien lages, nekter en mappe som ikke er tom, og kan kjøres med --modell."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from conftest import EKSEMPLER, FASIT, ROT, kjor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
KJEDE_S2 = EKSEMPLER / "scenarioer" / "notatet" / "kjede_s2.json"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def arbeidskopi(tmp_path: Path) -> Path:
|
||||
"""En arbeidskopi laget med kjor.py arbeidskopi."""
|
||||
mal = tmp_path / "rettsstat-modell"
|
||||
prosess = kjor("arbeidskopi", str(mal))
|
||||
assert prosess.returncode == 0, prosess.stderr
|
||||
assert f"Arbeidskopien er laget i {mal}" in prosess.stdout
|
||||
return mal
|
||||
|
||||
|
||||
def test_kopien_har_mappene_og_les_meg(arbeidskopi: Path) -> None:
|
||||
for navn in ("modell", "eksempler", "fasit", "tests"):
|
||||
assert (arbeidskopi / navn).is_dir(), navn
|
||||
for navn in ("modell/kjor.py", "modell/scenario.py", "modell/kjerne/sim.py", "modell/kjerne/KILDE.json",
|
||||
"modell/utvidelser/paadrag_tidsserie.py", "fasit/data/p3.dat",
|
||||
"eksempler/scenarioer/notatet/p3.json"):
|
||||
assert (arbeidskopi / navn).is_file(), navn
|
||||
for navn in ("modell/kjerne/sim.py", "fasit/data/endo_ko.dat"):
|
||||
assert (arbeidskopi / navn).read_bytes() == (ROT / navn).read_bytes()
|
||||
if (ROT / "docs").is_dir():
|
||||
assert sorted(p.name for p in (arbeidskopi / "docs").iterdir()) == \
|
||||
sorted(p.name for p in (ROT / "docs").iterdir() if p.is_file())
|
||||
les_meg = (arbeidskopi / "LES-MEG.md").read_text(encoding="utf-8")
|
||||
for tekst in ("Alt i mappa kan endres, også kjernen", f"python3 {arbeidskopi / 'modell' / 'kjor.py'} scenario",
|
||||
"kontroll", "«bit for bit som notatet»", "«endret: ...»", "docs/", "BIDRA.md"):
|
||||
assert tekst in les_meg, tekst
|
||||
|
||||
|
||||
def test_kopien_har_ikke_mellomlagre_eller_resultater(arbeidskopi: Path) -> None:
|
||||
uonsket = {".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", "resultater"}
|
||||
funnet = [p for p in arbeidskopi.rglob("*") if p.name in uonsket or p.suffix == ".pyc"]
|
||||
assert funnet == []
|
||||
|
||||
|
||||
def test_nekter_mappe_som_ikke_er_tom(tmp_path: Path) -> None:
|
||||
mal = tmp_path / "opptatt"
|
||||
mal.mkdir()
|
||||
(mal / "notat.txt").write_text("mitt", encoding="utf-8")
|
||||
prosess = kjor("arbeidskopi", str(mal))
|
||||
assert prosess.returncode == 2
|
||||
assert "finnes og er ikke tom" in prosess.stderr
|
||||
assert sorted(p.name for p in mal.iterdir()) == ["notat.txt"]
|
||||
|
||||
|
||||
def test_tom_mappe_er_greit(tmp_path: Path) -> None:
|
||||
mal = tmp_path / "tom"
|
||||
mal.mkdir()
|
||||
assert kjor("arbeidskopi", str(mal)).returncode == 0
|
||||
assert (mal / "LES-MEG.md").is_file()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("args", [[], ["a", "b"]])
|
||||
def test_feil_bruk(args: list[str]) -> None:
|
||||
prosess = kjor("arbeidskopi", *args)
|
||||
assert prosess.returncode == 2
|
||||
assert "arbeidskopi <mappe>" in prosess.stderr
|
||||
|
||||
|
||||
def test_nekter_mappe_inne_i_det_som_kopieres(tmp_path: Path, arbeidskopi: Path) -> None:
|
||||
mal = arbeidskopi / "eksempler" / "ny"
|
||||
prosess = kjor("--modell", str(arbeidskopi), "arbeidskopi", str(mal))
|
||||
assert prosess.returncode == 2, prosess.stdout
|
||||
assert "ligger inne i" in prosess.stderr
|
||||
assert not mal.exists()
|
||||
assert not (tmp_path / "ny").exists()
|
||||
|
||||
|
||||
def test_scenario_kjorer_fra_kopien_med_modell(arbeidskopi: Path, tmp_path: Path) -> None:
|
||||
ut = tmp_path / "ut"
|
||||
prosess = kjor("--modell", str(arbeidskopi), "scenario", str(KJEDE_S2), "--ut", str(ut))
|
||||
assert prosess.returncode == 0, prosess.stderr
|
||||
assert (ut / "kjede_s2.dat").read_bytes() == (FASIT / "data" / "kjede_s2.dat").read_bytes()
|
||||
|
||||
|
||||
def test_endring_i_kopiens_kjerne_brukes(arbeidskopi: Path, tmp_path: Path) -> None:
|
||||
sim = arbeidskopi / "modell" / "kjerne" / "sim.py"
|
||||
tekst = sim.read_text(encoding="utf-8")
|
||||
assert "shock=1.30," in tekst
|
||||
sim.write_text(tekst.replace("shock=1.30,", "shock=1.40,", 1), encoding="utf-8")
|
||||
ut = tmp_path / "ut"
|
||||
prosess = kjor("--modell", str(arbeidskopi), "scenario", str(KJEDE_S2), "--ut", str(ut))
|
||||
assert prosess.returncode == 0, prosess.stderr
|
||||
assert (ut / "kjede_s2.dat").read_bytes() != (FASIT / "data" / "kjede_s2.dat").read_bytes()
|
||||
plugin = kjor("scenario", str(KJEDE_S2), "--ut", str(tmp_path / "plugin"))
|
||||
assert plugin.returncode == 0, plugin.stderr
|
||||
assert (tmp_path / "plugin" / "kjede_s2.dat").read_bytes() == (FASIT / "data" / "kjede_s2.dat").read_bytes()
|
||||
304
tests/test_scenario.py
Normal file
304
tests/test_scenario.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
# Copyright (c) 2026 Kjell Tore Guttormsen. MIT License, see LICENSE.
|
||||
"""scenario.py: gjenskaping av notatets datafiler, validering, sveip, utdata og determinisme."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
import scenario
|
||||
from conftest import EKSEMPLER, FASIT, kjor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
NOTATET = EKSEMPLER / "scenarioer" / "notatet"
|
||||
NYE = EKSEMPLER / "scenarioer"
|
||||
EKSAKT_RASKE = ("p3", "p6", "p11", "p11d", "kjede_s1", "kjede_s2", "kjede_s3", "kjede_s4", "kjede_s5",
|
||||
"endo_e5", "endo_des", "endo_desp", "endo_ko")
|
||||
|
||||
# Kolonnene i notatets fil: (scenariofil, kjøring, kolonne i kjøringens fil).
|
||||
KOLONNEVIS: dict[str, dict[str, tuple[str, str, str]]] = {
|
||||
"sat_B": {"t": ("sat_B", "ksat=inf", "t"), "K": ("sat_B", "ksat=inf", "K"),
|
||||
"Ks3": ("sat_B", "ksat=3", "K"), "Ks15": ("sat_B", "ksat=1.5", "K")},
|
||||
"sat_C": {"t": ("sat_C", "ksat=inf", "t"), "K": ("sat_C", "ksat=inf", "K"),
|
||||
"Ks3": ("sat_C", "ksat=3", "K"), "Ks15": ("sat_C", "ksat=1.5", "K")},
|
||||
"vippe": {"t": ("vippe", "regulator=fast_forsinkelse=3", "t"),
|
||||
"fast": ("vippe", "regulator=fast_forsinkelse=3", "K"),
|
||||
"p3": ("vippe", "regulator=P_forsinkelse=3", "K"), "p6": ("vippe", "regulator=P_forsinkelse=6", "K"),
|
||||
"p3cap": ("vippe_tak", "forsinkelse=3", "K"), "p6cap": ("vippe_tak", "forsinkelse=6", "K")},
|
||||
}
|
||||
for _s in ("prio_s2", "prio_s3", "prio_s5"):
|
||||
KOLONNEVIS[_s] = {
|
||||
**{c: (_s, "prio=true", c) for c in ("t", "K", "KA", "KB", "tauA", "tauB")},
|
||||
"tauF": (_s, "prio=false", "tauA"), "KF": (_s, "prio=false", "K"), "KAF": (_s, "prio=false", "KA"),
|
||||
}
|
||||
|
||||
|
||||
def kjor_fil(fil: Path, ut: Path, capsys: pytest.CaptureFixture[str]) -> tuple[int, str, str]:
|
||||
"""Kjør scenario.main i denne prosessen."""
|
||||
kode = scenario.main([str(fil), "--ut", str(ut)])
|
||||
fanget = capsys.readouterr()
|
||||
return kode, fanget.out, fanget.err
|
||||
|
||||
|
||||
def skriv(tmp_path: Path, data: object, navn: str = "s.json") -> Path:
|
||||
"""Skriv en scenariofil."""
|
||||
fil = tmp_path / navn
|
||||
fil.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
return fil
|
||||
|
||||
|
||||
def kolonner(fil: Path) -> dict[str, list[str]]:
|
||||
"""Kolonnene i en fil i notatets format, som tekst."""
|
||||
linjer = fil.read_text(encoding="utf-8").splitlines()
|
||||
hode = linjer[0].split()
|
||||
rader = [linje.split(" ") for linje in linjer[1:]]
|
||||
return {h: [r[i] for r in rader] for i, h in enumerate(hode)}
|
||||
|
||||
|
||||
# ---------------- notatets datafiler ----------------
|
||||
|
||||
@pytest.mark.parametrize("navn", EKSAKT_RASKE)
|
||||
def test_gjenskaper_notatets_fil_eksakt(navn: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
kode, _, err = kjor_fil(NOTATET / f"{navn}.json", tmp_path, capsys)
|
||||
assert kode == 0, err
|
||||
assert (tmp_path / f"{navn}.dat").read_bytes() == (FASIT / "data" / f"{navn}.dat").read_bytes()
|
||||
|
||||
|
||||
@pytest.mark.treg
|
||||
def test_gjenskaper_mpc11_eksakt(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
kode, _, err = kjor_fil(NOTATET / "mpc11.json", tmp_path, capsys)
|
||||
assert kode == 0, err
|
||||
assert (tmp_path / "mpc11.dat").read_bytes() == (FASIT / "data" / "mpc11.dat").read_bytes()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("navn", sorted(KOLONNEVIS))
|
||||
def test_gjenskaper_notatets_fil_kolonnevis(navn: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
kjort: dict[str, Path] = {}
|
||||
for scenariofil, _, _ in KOLONNEVIS[navn].values():
|
||||
if scenariofil not in kjort:
|
||||
kode, _, err = kjor_fil(NOTATET / f"{scenariofil}.json", tmp_path / scenariofil, capsys)
|
||||
assert kode == 0, err
|
||||
kjort[scenariofil] = tmp_path / scenariofil
|
||||
fasit = kolonner(FASIT / "data" / f"{navn}.dat")
|
||||
assert set(fasit) == set(KOLONNEVIS[navn])
|
||||
for kolonne, (scenariofil, kjoring, kilde) in KOLONNEVIS[navn].items():
|
||||
egen = kolonner(kjort[scenariofil] / f"{scenariofil}__{kjoring}.dat")
|
||||
assert egen[kilde] == fasit[kolonne], f"{navn}: kolonne {kolonne}"
|
||||
|
||||
|
||||
def test_alle_notatets_simuleringer_har_en_scenariofil() -> None:
|
||||
simulerte = {f.stem for f in (FASIT / "data").glob("*.dat")} - {"stab", "thumb"}
|
||||
dekket = set(EKSAKT_RASKE) | {"mpc11"} | set(KOLONNEVIS)
|
||||
assert simulerte == dekket
|
||||
|
||||
|
||||
# ---------------- utdata ----------------
|
||||
|
||||
def test_sammendrag_csv_og_resultatjson(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
fil = skriv(tmp_path, {"navn": "test", "modell": "simulate_chain",
|
||||
"tiltak": {"kapasitet": {"faktor": 1.34}}})
|
||||
kode, ut, _ = kjor_fil(fil, tmp_path / "ut", capsys)
|
||||
assert kode == 0
|
||||
mappe = tmp_path / "ut"
|
||||
assert sorted(p.name for p in mappe.iterdir()) == ["resultat.json", "sammendrag.md", "test.csv"]
|
||||
assert ut.startswith((mappe / "sammendrag.md").read_text(encoding="utf-8"))
|
||||
assert f"Resultatene ligger i {mappe}" in ut
|
||||
assert "ikke kalibrert på norske data" in ut
|
||||
assert "ikke prognoser" in ut
|
||||
assert "tiltak.kapasitet.faktor" in ut
|
||||
assert "Slik leses tallene: 1,00 er nivået" in ut
|
||||
csv = (mappe / "test.csv").read_text(encoding="utf-8").splitlines()
|
||||
assert csv[0] == "t,K,U,L,T,Q,tauD,JD,RD,xi,Z,Z_rel"
|
||||
assert len(csv) == 1 + 121 # én rad per måned, 0-120
|
||||
assert csv[13].split(",")[0] == "12"
|
||||
data = json.loads((mappe / "resultat.json").read_text(encoding="utf-8"))
|
||||
assert set(data["versjoner"]) == {"python", "numpy", "scipy"}
|
||||
assert set(data["kjerne_sha256"]) == {"sim.py", "diagnose.py", "forlop.py"}
|
||||
brukt = data["kjoringer"][0]["brukt"]
|
||||
assert brukt["parametere"]["P"]["ksat"] == "inf"
|
||||
assert brukt["tiltak"]["kapasitet"] == {"faktor": 1.34, "fra": 24.0, "over": 24.0}
|
||||
assert data["kjoringer"][0]["nokkeltall"]["Z_slutt"] == pytest.approx(1.34)
|
||||
|
||||
|
||||
def test_standard_mappe_er_resultater_under_arbeidsmappa(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fil = skriv(tmp_path, {"navn": "Første forsøk", "modell": "simulate"})
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert scenario.main([str(fil)]) == 0
|
||||
capsys.readouterr()
|
||||
assert (tmp_path / "resultater" / "Forste-forsok" / "Forste-forsok.csv").is_file()
|
||||
|
||||
|
||||
def test_samme_inndata_gir_samme_bytes(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
fil = NYE / "utvidelse_bolge.json"
|
||||
for mappe in ("a", "b"):
|
||||
assert kjor_fil(fil, tmp_path / mappe, capsys)[0] == 0
|
||||
a = sorted((tmp_path / "a").iterdir())
|
||||
assert [p.name for p in a] == sorted(p.name for p in (tmp_path / "b").iterdir())
|
||||
for p in a:
|
||||
assert p.read_bytes() == (tmp_path / "b" / p.name).read_bytes(), p.name
|
||||
|
||||
|
||||
def test_ny_kjoring_i_samme_mappe_rydder_bare_egne_filer(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
sveip = skriv(tmp_path, {"navn": "x", "modell": "simulate",
|
||||
"sveip": {"parameter": "styring.forsinkelse", "verdier": [3, 6]}})
|
||||
enkel = skriv(tmp_path, {"navn": "x", "modell": "simulate"}, "enkel.json")
|
||||
ut = tmp_path / "ut"
|
||||
assert kjor_fil(sveip, ut, capsys)[0] == 0
|
||||
(ut / "notat.txt").write_text("min egen fil", encoding="utf-8")
|
||||
assert kjor_fil(enkel, ut, capsys)[0] == 0
|
||||
assert sorted(p.name for p in ut.iterdir()) == ["notat.txt", "resultat.json", "sammendrag.md", "x.csv"]
|
||||
|
||||
|
||||
def test_nekter_mappe_med_andre_filer(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
fil = skriv(tmp_path, {"modell": "simulate"})
|
||||
ut = tmp_path / "ut"
|
||||
ut.mkdir()
|
||||
(ut / "viktig.txt").write_text("x", encoding="utf-8")
|
||||
kode, _, err = kjor_fil(fil, ut, capsys)
|
||||
assert kode == 2
|
||||
assert "ikke er et tidligere resultat" in err
|
||||
assert sorted(p.name for p in ut.iterdir()) == ["viktig.txt"]
|
||||
|
||||
|
||||
def test_valgte_kolonner_og_hvert(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
fil = skriv(tmp_path, {"navn": "k", "modell": "simulate_endo", "regel": "koord", "slutt": 36,
|
||||
"utdata": {"kolonner": ["t", "Z_rel", "xi"], "hvert": 240}})
|
||||
assert kjor_fil(fil, tmp_path / "ut", capsys)[0] == 0
|
||||
linjer = (tmp_path / "ut" / "k.csv").read_text(encoding="utf-8").splitlines()
|
||||
assert linjer[0] == "t,Z_rel,xi"
|
||||
assert [linje.split(",")[0] for linje in linjer[1:]] == ["0", "12", "24", "36"]
|
||||
assert linjer[1] == "0,1,1"
|
||||
|
||||
|
||||
# ---------------- sveip ----------------
|
||||
|
||||
def test_sveip_over_avviklingstid(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
fil = skriv(tmp_path, {"navn": "ko", "modell": "simulate_endo", "regel": "koord", "slutt": 180,
|
||||
"sveip": {"parameter": "parametere.ENDO.T_clear", "verdier": [12, None, "inf"]}})
|
||||
kode, ut, _ = kjor_fil(fil, tmp_path / "ut", capsys)
|
||||
assert kode == 0
|
||||
data = json.loads((tmp_path / "ut" / "resultat.json").read_text(encoding="utf-8"))
|
||||
brukt = [k["brukt"]["parametere"]["ENDO"]["T_clear"] for k in data["kjoringer"]]
|
||||
assert brukt == [12.0, 24.0, "inf"] # null = notatets verdi
|
||||
assert [k["fil"] for k in data["kjoringer"]] == [
|
||||
"ko__T_clear=12.csv", "ko__T_clear=standard.csv", "ko__T_clear=inf.csv"]
|
||||
standard = data["kjoringer"][1]
|
||||
assert standard["vedtak"] == ["mnd 24: bestill +19 % (F_pred=0.134 Q_exc=0.27)",
|
||||
"mnd 36: bestill +12 % (F_pred=0.134 Q_exc=0.56)"]
|
||||
assert round(standard["nokkeltall"]["kapasitetsaar"], 1) == 3.5 # notatets tabell i avsnitt 8.5
|
||||
assert round(standard["nokkeltall"]["frafall"], 1) == 8.3
|
||||
assert "T_clear=standard" in ut
|
||||
|
||||
|
||||
def test_rutenett_gir_alle_kombinasjoner_og_matrise(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
fil = skriv(tmp_path, {"navn": "g", "modell": "simulate", "slutt": 150,
|
||||
"sveip": [{"parameter": "styring.reaktivitet", "verdier": [1, 3]},
|
||||
{"parameter": "styring.forsinkelse", "verdier": [3, 11]}]})
|
||||
kode, ut, _ = kjor_fil(fil, tmp_path / "ut", capsys)
|
||||
assert kode == 0
|
||||
data = json.loads((tmp_path / "ut" / "resultat.json").read_text(encoding="utf-8"))
|
||||
par = [(k["brukt"]["styring"]["reaktivitet"], k["brukt"]["styring"]["forsinkelse"]) for k in data["kjoringer"]]
|
||||
assert par == [(1.0, 3.0), (1.0, 11.0), (3.0, 3.0), (3.0, 11.0)]
|
||||
spenn = {p: k["nokkeltall"]["spenn_K_sist"] for p, k in zip(par, data["kjoringer"])}
|
||||
assert spenn[3.0, 11.0] > 0.3 > spenn[3.0, 3.0] # regime C svinger, regime A ikke
|
||||
assert "rader: styring.reaktivitet, kolonner: styring.forsinkelse" in ut
|
||||
|
||||
|
||||
def test_sveip_gjennom_kjor_py(tmp_path: Path) -> None:
|
||||
prosess = kjor("scenario", str(NOTATET / "sat_B.json"), "--ut", str(tmp_path))
|
||||
assert prosess.returncode == 0, prosess.stderr
|
||||
assert "ksat=1,5" in prosess.stdout
|
||||
assert len(list(tmp_path.glob("*.dat"))) == 3
|
||||
|
||||
|
||||
# ---------------- validering ----------------
|
||||
|
||||
@pytest.mark.parametrize(("data", "tekster"), [
|
||||
({"modell": "simulate", "styrng": {}}, ["styrng: ukjent felt", 'Mente du "styring"?', "Gyldige felt her:"]),
|
||||
({"modell": "simulat"}, ["modell: må være ett av", 'Mente du "simulate"?']),
|
||||
({"modell": "simulate", "parametere": {"P": {"gama": 0.1}}},
|
||||
["parametere.P.gama: ukjent parameter i P", 'Mente du "gamma"?', "Gyldige: gamma, eta"]),
|
||||
({"modell": "simulate", "parametere": {"CH0": {"Z": 1}}},
|
||||
["parametere.CH0: brukes ikke av modellen simulate", "bruker: P"]),
|
||||
({"modell": "simulate_chain", "parametere": {"CH0": {"Zfun": 1}}}, ["settes med tiltak.kapasitet"]),
|
||||
({"modell": "simulate", "styring": {"forsinkelse": "tre"}},
|
||||
["styring.forsinkelse: må være et tall minst 0", '"tre"']),
|
||||
({"modell": "simulate", "parametere": {"P": {"Lam0": 1.2}}}, ["parametere.P.Lam0", "mindre enn 1"]),
|
||||
({"modell": "simulate", "parametere": {"P": {"gamma": "inf"}}}, ["uendelig er ikke tillatt her"]),
|
||||
({"modell": "simulate_chain2", "parametere": {"CH2": {"c": 2.5}}}, ["parametere.CH2.c: må være et heltall"]),
|
||||
({"modell": "simulate_endo", "regel": "koord", "parametere": {"ENDO": {"qloop": 1}}},
|
||||
["parametere.ENDO.qloop: må være true eller false"]),
|
||||
({"modell": "simulate_endo"}, ["regel: mangler", "sched, desentral, desentral_prog, koord"]),
|
||||
({"modell": "simulate", "regel": "koord"}, ["regel: gjelder bare simulate_endo"]),
|
||||
({"modell": "simulate_chain2", "styring": {"regulator": "MPC"}}, ["styring.regulator", '"P"']),
|
||||
({"modell": "simulate", "sjokk": {"til": 36}}, ["sjokk.til: gjelder bare simulate_soc", "paadrag_tidsserie"]),
|
||||
({"modell": "simulate", "tiltak": {"kapasitet": {"faktor": 1.2}}},
|
||||
["tiltak.kapasitet: brukes ikke av modellen simulate", "simulate_chain, simulate_chain2, simulate_endo"]),
|
||||
({"modell": "simulate_endo", "regel": "koord", "tiltak": {"kapasitet": {"faktor": 1.2}}},
|
||||
['bare regel "sched"']),
|
||||
({"modell": "simulate_chain", "parametere": {"CH0": {"Z": "inf"}}, "tiltak": {"kapasitet": {"faktor": 1.2}}},
|
||||
["tiltak.kapasitet: har ingen mening"]),
|
||||
({"modell": "simulate", "sjokk": {"faktor": 1.2}, "parametere": {"P": {"shock": 1.2}}},
|
||||
["sjokk.faktor: er oppgitt to ganger"]),
|
||||
({"modell": "simulate", "sveip": {"parameter": "styring.reaktivtet", "verdier": [1, 2]}},
|
||||
["sveip[0].parameter", 'ukjent felt "reaktivtet" under styring', 'Mente du "reaktivitet"?']),
|
||||
({"modell": "simulate", "sveip": {"parameter": "parametere.P", "verdier": [1]}},
|
||||
["er en gruppe, ikke én verdi", "parametere.P.gamma"]),
|
||||
({"modell": "simulate", "sveip": {"parameter": "navn", "verdier": ["a"]}},
|
||||
["begynner ikke med et felt som kan sveipes"]),
|
||||
({"modell": "simulate", "sveip": {"parameter": "styring.forsinkelse", "verdier": [3, -1]}},
|
||||
["styring.forsinkelse: må være et tall minst 0", "(i sveipet: styring.forsinkelse = -1)"]),
|
||||
({"modell": "simulate", "sveip": {"parameter": "styring.forsinkelse", "verdier": [3, 3.0]}},
|
||||
["har samme verdi to ganger"]),
|
||||
({"modell": "simulate", "utdata": {"kolonner": ["t", "Q"]}}, ['utdata.kolonner: "Q" finnes ikke', "t, K, U, L, T"]),
|
||||
({"modell": "simulate", "utdata": {"overskrift": "t K"}}, ['gjelder bare "format": "notat"']),
|
||||
({"modell": "utvidelse:finnes_ikke"}, ['fant ikke utvidelsen "finnes_ikke"', "utvidelse:paadrag_tidsserie"]),
|
||||
])
|
||||
def test_feil_i_scenariofila_gir_kode_2_og_navngir_feltet(
|
||||
data: object, tekster: list[str], tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
fil = skriv(tmp_path, data)
|
||||
kode, ut, err = kjor_fil(fil, tmp_path / "ut", capsys)
|
||||
assert kode == 2
|
||||
assert ut == ""
|
||||
assert err.startswith(f"Feil i scenariofila {fil}: ")
|
||||
for t in tekster:
|
||||
assert t in err, err
|
||||
assert not (tmp_path / "ut").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("innhold", "tekst"), [
|
||||
('{"modell": "simulate",\n "slutt": 120,,}', "ikke gyldig JSON: feil på linje 2, kolonne 15"),
|
||||
('{"modell": "simulate", "parametere": {"P": {"ksat": Infinity}}}', 'Skriv uendelig som "inf"'),
|
||||
('{"modell": "simulate", "slutt": 120, "slutt": 60}', "slutt: feltet står to ganger"),
|
||||
("[1, 2]", "må være ett JSON-objekt"),
|
||||
])
|
||||
def test_feil_json(innhold: str, tekst: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
fil = tmp_path / "s.json"
|
||||
fil.write_text(innhold, encoding="utf-8")
|
||||
kode, _, err = kjor_fil(fil, tmp_path / "ut", capsys)
|
||||
assert kode == 2
|
||||
assert tekst in err, err
|
||||
|
||||
|
||||
def test_fil_som_mangler_og_feil_bruk(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert kjor_fil(tmp_path / "finnes_ikke.json", tmp_path / "ut", capsys)[2].startswith("Fant ikke fila")
|
||||
assert scenario.main([]) == 2
|
||||
assert "Mangler scenariofil" in capsys.readouterr().err
|
||||
assert scenario.main(["a.json", "--tull"]) == 2
|
||||
assert "Ukjent valg --tull" in capsys.readouterr().err
|
||||
assert scenario.main(["--hjelp"]) == 0
|
||||
assert capsys.readouterr().out.startswith("Bruk:")
|
||||
|
||||
|
||||
def test_alle_eksempelscenarioer_er_gyldige() -> None:
|
||||
filer = sorted(NYE.glob("*.json")) + sorted(NOTATET.glob("*.json"))
|
||||
assert len(filer) >= 26
|
||||
for fil in filer:
|
||||
sc = scenario.les_scenario(fil)
|
||||
assert sc.kjoringer, fil.name
|
||||
if fil.parent == NYE:
|
||||
assert sc.beskrivelse, f"{fil.name} mangler beskrivelse"
|
||||
158
tests/test_utvidelser.py
Normal file
158
tests/test_utvidelser.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
# Copyright (c) 2026 Kjell Tore Guttormsen. MIT License, see LICENSE.
|
||||
"""Utvidelsene: grensetilfellene gjenskaper kjernen eksakt, og paadrag_tidsserie virker i scenariokjøreren."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
import scenario
|
||||
from conftest import FASIT, MODELL, ROT, kjor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
UTVIDELSER = sorted(f.stem for f in (MODELL / "utvidelser").glob("*.py") if not f.stem.startswith("_"))
|
||||
|
||||
|
||||
def kjor_fil(data: object, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> tuple[int, str]:
|
||||
"""Skriv og kjør en scenariofil; returnerer exit-kode og feilmelding."""
|
||||
fil = tmp_path / "s.json"
|
||||
fil.write_text(json.dumps(data), encoding="utf-8")
|
||||
kode = scenario.main([str(fil), "--ut", str(tmp_path / "ut")])
|
||||
return kode, capsys.readouterr().err
|
||||
|
||||
|
||||
def test_det_finnes_minst_en_utvidelse() -> None:
|
||||
assert "paadrag_tidsserie" in UTVIDELSER
|
||||
|
||||
|
||||
@pytest.mark.parametrize("navn", UTVIDELSER)
|
||||
def test_grensetilfellene_gjenskaper_kjernen_eksakt(navn: str) -> None:
|
||||
modul = scenario.last_utvidelse(navn)
|
||||
tilfeller = modul.grensetilfelle()
|
||||
assert tilfeller, f"{navn} har ingen grensetilfeller"
|
||||
for beskrivelse, avvik in tilfeller:
|
||||
assert isinstance(beskrivelse, str)
|
||||
assert avvik == 0.0, f"{navn}: {beskrivelse}: {avvik}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("navn", UTVIDELSER)
|
||||
def test_grensesnittet(navn: str) -> None:
|
||||
modul = scenario.last_utvidelse(navn)
|
||||
assert isinstance(modul.NAVN, str)
|
||||
assert navn == modul.NAVN
|
||||
assert isinstance(modul.BESKRIVELSE, str)
|
||||
assert isinstance(modul.PARAMETERE, dict)
|
||||
assert callable(modul.simuler)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("ch0", "fasitfil"), [({}, "kjede_s2"), ({"Z": "inf"}, "p3")])
|
||||
def test_standardverdiene_gjenskaper_notatets_fil_gjennom_kjoreren(
|
||||
ch0: dict[str, object], fasitfil: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
kolonner = ["t", "K", "U", "L", "T", "Q", "tauD"] if fasitfil.startswith("kjede") else ["t", "K", "U", "L", "T"]
|
||||
data = {"navn": "u", "modell": "utvidelse:paadrag_tidsserie", "parametere": {"CH0": ch0},
|
||||
"utdata": {"format": "notat", "kolonner": kolonner}}
|
||||
kode, err = kjor_fil(data, tmp_path, capsys)
|
||||
assert kode == 0, err
|
||||
assert (tmp_path / "ut" / "u.dat").read_bytes() == (FASIT / "data" / f"{fasitfil}.dat").read_bytes()
|
||||
|
||||
|
||||
def test_tiltak_i_kjeden_virker_som_i_kjernen(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
data = {"navn": "u", "modell": "utvidelse:paadrag_tidsserie",
|
||||
"tiltak": {"andel_utenom": {"andel": 0.2}, "kapasitet": {"faktor": 1.34}},
|
||||
"utdata": {"format": "notat", "kolonner": ["t", "K", "U", "L", "T", "Q", "tauD"]}}
|
||||
kode, err = kjor_fil(data, tmp_path, capsys)
|
||||
assert kode == 0, err
|
||||
assert (tmp_path / "ut" / "u.dat").read_bytes() == (FASIT / "data" / "kjede_s5.dat").read_bytes()
|
||||
|
||||
|
||||
def test_faktor_trapp_og_lineaer() -> None:
|
||||
modul = importlib.import_module("utvidelser.paadrag_tidsserie")
|
||||
punkter = [(12.0, 1.0), (18.0, 1.4), (36.0, 1.4), (48.0, 1.0)]
|
||||
assert [modul.faktor(t, "lineaer", punkter) for t in (0.0, 12.0, 15.0, 18.0, 30.0, 42.0, 60.0)] == \
|
||||
pytest.approx([1.0, 1.0, 1.2, 1.4, 1.4, 1.2, 1.0])
|
||||
trapp = [(12.0, 1.3), (24.0, 1.1)]
|
||||
assert [modul.faktor(t, "trapp", trapp) for t in (11.95, 12.0, 23.95, 24.0, 100.0)] == [1.0, 1.3, 1.3, 1.1, 1.1]
|
||||
|
||||
|
||||
def test_bolge_gir_forbigaaende_paadrag(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
data = {"navn": "u", "modell": "utvidelse:paadrag_tidsserie", "slutt": 120,
|
||||
"parametere": {"CH0": {"Z": "inf"}},
|
||||
"utvidelse": {"form": "lineaer", "punkter": [[12, 1.0], [18, 1.4], [36, 1.4], [48, 1.0]]},
|
||||
"utdata": {"kolonner": ["t", "K", "Omega_faktor"], "hvert": 240}}
|
||||
kode, err = kjor_fil(data, tmp_path, capsys)
|
||||
assert kode == 0, err
|
||||
rader = [linje.split(",") for linje in (tmp_path / "ut" / "u.csv").read_text(encoding="utf-8").splitlines()[1:]]
|
||||
assert [r[2] for r in rader] == ["1", "1", "1.4", "1.4", "1", "1", "1", "1", "1", "1", "1"]
|
||||
k = [float(r[1]) for r in rader]
|
||||
assert max(k) > 1.05
|
||||
assert abs(k[-1] - 1.0) < 0.01 # uten flaskehals går K tilbake når presset er borte
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("ekstra", "tekst"), [
|
||||
({"utvidelse": {"punkter": [[12, 1.3], [6, 1.0]]}}, "utvidelse.punkter[1]: månedene må stige"),
|
||||
({"utvidelse": {"punkter": [[12]]}}, "utvidelse.punkter[0]: må være et par"),
|
||||
({"utvidelse": {"punkter": 1.3}}, "utvidelse.punkter: må være en liste"),
|
||||
({"utvidelse": {"form": "glatt"}}, 'utvidelse.form: må være "trapp" eller "lineaer"'),
|
||||
({"utvidelse": {"fart": 2}}, "utvidelse.fart: ukjent felt"),
|
||||
({"styring": {"regulator": "MPC"}}, 'bruker bare "P"'),
|
||||
({"sjokk": {"faktor": 1.2}}, "sjokk: brukes ikke med utvidelser"),
|
||||
({"parametere": {"P": {"shock": 1.5}}}, "parametere.P.shock: brukes ikke av paadrag_tidsserie"),
|
||||
({"parametere": {"CH2": {"c": 2}}}, "parametere.CH2: brukes ikke av modellen"),
|
||||
({"parametere": {"CH0": {"Z": "inf"}}, "tiltak": {"kapasitet": {"faktor": 1.2}}}, "tiltak: virker bare i kjeden"),
|
||||
])
|
||||
def test_feil_i_utvidelsens_parametere(ekstra: dict[str, object], tekst: str, tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str]) -> None:
|
||||
kode, err = kjor_fil({"modell": "utvidelse:paadrag_tidsserie", **ekstra}, tmp_path, capsys)
|
||||
assert kode == 2
|
||||
assert tekst in err, err
|
||||
|
||||
|
||||
# ---------------- malen i docs/utvidelser.md ----------------
|
||||
|
||||
def malen() -> str:
|
||||
"""Python-koden mellom markørene i docs/utvidelser.md."""
|
||||
tekst = (ROT / "docs" / "utvidelser.md").read_text(encoding="utf-8")
|
||||
blokk = tekst.split("<!-- mal:start -->", 1)[1].split("<!-- mal:slutt -->", 1)[0]
|
||||
return blokk.split("```python\n", 1)[1].rsplit("```", 1)[0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def modell_med_mal(tmp_path: Path) -> Path:
|
||||
"""En kopi av modell/ og fasit/ med malen lagt inn som modell/utvidelser/mal_effektivitet.py."""
|
||||
rot = tmp_path / "med_mal"
|
||||
for navn in ("modell", "fasit"):
|
||||
shutil.copytree(ROT / navn, rot / navn, ignore=shutil.ignore_patterns("__pycache__"))
|
||||
(rot / "modell" / "utvidelser" / "mal_effektivitet.py").write_text(malen(), encoding="utf-8")
|
||||
return rot
|
||||
|
||||
|
||||
def test_malen_har_eksakt_grensetilfelle(modell_med_mal: Path) -> None:
|
||||
modell = modell_med_mal / "modell"
|
||||
kode = ("import importlib, json, sys; sys.path[:0] = [sys.argv[1], sys.argv[2]]; "
|
||||
"m = importlib.import_module('utvidelser.mal_effektivitet'); print(json.dumps(m.grensetilfelle()))")
|
||||
prosess = subprocess.run([sys.executable, "-c", kode, str(modell / "kjerne"), str(modell)],
|
||||
capture_output=True, text=True, check=False)
|
||||
assert prosess.returncode == 0, prosess.stderr
|
||||
tilfeller = json.loads(prosess.stdout)
|
||||
assert len(tilfeller) == 2
|
||||
assert all(avvik == 0.0 for _, avvik in tilfeller), tilfeller
|
||||
|
||||
|
||||
def test_malen_kjorer_med_scenariofil(modell_med_mal: Path, tmp_path: Path) -> None:
|
||||
fil = tmp_path / "mal.json"
|
||||
for faktor, navn in ((1.0, "en"), (1.2, "annen")):
|
||||
fil.write_text(json.dumps({"navn": navn, "modell": "utvidelse:mal_effektivitet",
|
||||
"utvidelse": {"faktor": faktor},
|
||||
"utdata": {"format": "notat", "kolonner": ["t", "K", "U", "L", "T"]}}),
|
||||
encoding="utf-8")
|
||||
prosess = kjor("--modell", str(modell_med_mal), "scenario", str(fil), "--ut", str(tmp_path / navn))
|
||||
assert prosess.returncode == 0, prosess.stderr
|
||||
assert (tmp_path / "en" / "en.dat").read_bytes() == (FASIT / "data" / "p3.dat").read_bytes()
|
||||
assert (tmp_path / "annen" / "annen.dat").read_bytes() != (FASIT / "data" / "p3.dat").read_bytes()
|
||||
Loading…
Add table
Add a link
Reference in a new issue