portfolio-optimiser/tests/test_bygg_energi_mikro.py
Kjell Tore Guttormsen cbc7a22c78 docs(shared): bygg-energi mikro-eksempel — OKF-bundle + golden + load-bearing test
Persistent dev-fixture for energieffektivisering (energiledelse/M&V), valgt for
sin lærings-overflate: gapet mellom modellert besparelse (validatoren regner) og
faktisk realisert besparelse i drift (eksperten kjenner) — det ExpeL skal lære.

Ett kontorbygg, ett LED-retrofit-tiltak. OKF-bundle (index/project/hypothesis/
methodology/reference/verdict) bærer kontekst-laget; verdict-led-fro.md koder
realiseringsgraden (RR ≈ 0,82, forankret i National Grid SBS 2010) som ExpeL-frø.

Energi mappet inn i den EKSISTERENDE kost-IR-en uendret (affected = byggets totale
energikostnad, claimed = modellert besparelse ~10 % < 30 %-cap), så validatoren
kjører som-den-er — src/ urørt. golden.json fryser de seeded percentilene; testen
beviser at fixturen er konsumerbar (validerer, ikke Rejection), ikke bare til stede.

Domenetall verifisert mot primærkilder (EVO/IPMVP, DOE/NREL UMP, CPUC, fire
evalueringsstudier); norsk energipris mot SSB Q1 2026. README + shared/README
oppdatert (eksempel finnes, ikke lenger "planned"). Suite 121/4, ruff+mypy rene.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHR8iKxJRxDiDfNw8HZmWE
2026-06-29 09:42:13 +02:00

123 lines
5.1 KiB
Python

"""Bygg-energi mikro-eksempel — fixture-konsumerbarhet (shared/examples/bygg-energi-mikro).
The energi micro-fixture is a *dev fixture* exercised through the whole build. These tests
prove it is actually consumable, not merely present:
1. the IR projection (``validator-input.json``) drives the EXISTING deterministic validator to
a ``ValidatedProposal`` (not a ``Rejection``) — energi mapped into the cost-IR unchanged;
2. that validator output is frozen against ``golden.json`` (seeded ``_MC_SEED=20260624``);
3. the OKF bundle is well-formed (every file declares ``type``; index cross-links resolve);
4. the seed verdict encodes a realization gap (RR < 1) consistent with the golden — the anchor
the later step-1 ExpeL wiring becomes load-bearing against.
No ``yaml`` dependency: frontmatter is parsed with a minimal key:value reader.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
from portfolio_optimiser.ir import AffectedItem, SavingsProposal
from portfolio_optimiser.validator import Rejection, ValidatedProposal, validate_proposal
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
_EXPECTED_TYPES = {
"index.md": "index",
"bygg-kontor-nord.md": "project",
"tiltak-led-retrofit.md": "hypothesis",
"metode-ipmvp-a.md": "methodology",
"kilder-realiseringsgap.md": "reference",
"verdict-led-fro.md": "verdict",
}
def _parse_frontmatter(path: Path) -> dict[str, str]:
"""Minimal YAML-frontmatter reader: the ``---``-delimited leading block as key:value
strings. Enough for ``type`` and the verdict's scalar fields; list values (``tags: [...]``)
are kept verbatim and unused."""
lines = path.read_text(encoding="utf-8").splitlines()
if not lines or lines[0].strip() != "---":
return {}
fm: dict[str, str] = {}
for line in lines[1:]:
if line.strip() == "---":
break
key, sep, val = line.partition(":")
if sep:
fm[key.strip()] = val.strip()
return fm
def _proposal_from_input() -> SavingsProposal:
raw = json.loads((BUNDLE_DIR / "validator-input.json").read_text(encoding="utf-8"))
return SavingsProposal(
project_id=raw["project_id"],
measure=raw["measure"],
affected_items=[AffectedItem(**a) for a in raw["affected_items"]],
claimed_saving_nok=raw["claimed_saving_nok"],
assumptions={k: tuple(v) for k, v in raw["assumptions"].items()},
)
def _golden() -> dict:
return json.loads((BUNDLE_DIR / "golden.json").read_text(encoding="utf-8"))
def test_validator_input_validates() -> None:
"""The core: the energi tiltak, mapped into the cost-IR, passes the blocking validator —
a ``ValidatedProposal``, never a ``Rejection`` (claimed 30k < P90 feasible)."""
result = validate_proposal(_proposal_from_input())
assert isinstance(result, ValidatedProposal)
assert not isinstance(result, Rejection)
assert result.proposal.claimed_saving_nok < result.p90
assert result.p10 <= result.p50 <= result.p90
def test_validator_output_matches_golden() -> None:
"""Regression: the deterministic (seeded) percentiles match the frozen golden."""
result = validate_proposal(_proposal_from_input())
assert isinstance(result, ValidatedProposal)
g = _golden()["validator"]
assert g["validates"] is True
assert result.nominal_feasible == pytest.approx(g["nominal_feasible"])
assert result.p10 == pytest.approx(g["p10"])
assert result.p50 == pytest.approx(g["p50"])
assert result.p90 == pytest.approx(g["p90"])
def test_bundle_files_declare_type() -> None:
"""Every OKF bundle file carries the one required field (``type``) with the expected value."""
for name, expected_type in _EXPECTED_TYPES.items():
fm = _parse_frontmatter(BUNDLE_DIR / name)
assert fm.get("type") == expected_type, f"{name}: type={fm.get('type')!r}"
def test_index_crosslinks_resolve() -> None:
"""Intra-bundle cross-links in ``index.md`` resolve to existing files (our own example
must have no broken links, even though OKF consumers must tolerate them)."""
index = (BUNDLE_DIR / "index.md").read_text(encoding="utf-8")
targets = re.findall(r"\]\(([^)]+\.md)\)", index)
internal = [t for t in targets if "/" not in t]
assert internal, "expected intra-bundle .md links in index.md"
for target in internal:
assert (BUNDLE_DIR / target).exists(), f"index links to missing {target}"
def test_verdict_seed_encodes_realization_gap() -> None:
"""The seed verdict encodes a realization gap (RR < 1), and the golden learning-surface is
internally consistent (expected_actual = RR x modelled). This is the anchor the later
step-1 ExpeL wiring is made load-bearing against."""
fm = _parse_frontmatter(BUNDLE_DIR / "verdict-led-fro.md")
rr = float(fm["realization_rate"])
assert 0.0 < rr < 1.0, "a realization gap means RR strictly below 1"
ls = _golden()["learning_surface"]
assert ls["realization_rate"] == pytest.approx(rr)
assert ls["expected_actual_saving_nok"] == pytest.approx(
ls["realization_rate"] * ls["modelled_saving_nok"]
)