feat(fase1): assessment-method encoding — persisted dimension-scoped example + validator rule (F1)

This commit is contained in:
Kjell Tore Guttormsen 2026-07-07 08:15:44 +02:00
commit 69ca508677
4 changed files with 171 additions and 0 deletions

View file

@ -0,0 +1,19 @@
---
type: index
okf_version: 0.1
title: "Energi — vurderingsmetode-encodinger (MAF-repo-lokalt)"
description: "Persisterte, dimensjon-scopede vurderingsmetode-encodinger for energi-dimensjonen, navigerbare via OKF."
dimension: energi
tags: [metode, encoding, energi, IPMVP]
timestamp: 2026-07-06
---
# Energi — vurderingsmetode-encodinger
Et persistert, **MAF-repo-lokalt** metode-bundle. Det bor her (ikke i `shared/`) fordi `shared/` er
et PULL-ONLY subtree som ikke kan bære `dimension:`-frontmatter herfra. Bundelet encoder ≥1
vurderingsmetode mot referanse-domenet (energieffektivisering), dimensjon-scopet så
`okf.bundle_context(..., dimension="energi")` navigerer den (progressiv disclosure), og en annen
dimensjon utelater den.
- [IPMVP Option A — energi-metode](metode-ipmvp-a.md)

View file

@ -0,0 +1,29 @@
---
type: methodology
title: "IPMVP Option A — Retrofit Isolation, Key Parameter Measurement (energi)"
description: "M&V-metoden for å verifisere energibesparelsen fra ett isolert tiltak: mål nøkkelparameteren (effekt), estimér resten (driftstimer). Dimensjon-scopet encoding."
dimension: energi
methodology: IPMVP
option: A
tags: [IPMVP, M&V, EVO, energi, retrofit-isolation]
timestamp: 2026-07-06
---
# M&V-metode: IPMVP Option A (energi-dimensjon)
ENERGI-METODE-IPMVP-A-SENTINEL — denne encodede metoden er dimensjon-scopet til `energi`, så den
når kun agent-konteksten når kjøringen scopes til energi-dimensjonen.
**IPMVP** (International Performance Measurement and Verification Protocol) er konsensus-rammeverket
for å måle og verifisere energibesparelser, eid av **EVO** (Efficiency Valuation Organization).
Kjerneinnsikten: besparelse er en **kontrafaktisk** størrelse — den *beregnes*
(`Baseline Rapportering ± justeringer`), den måles ikke direkte.
## Option A — Retrofit Isolation: Key Parameter Measurement
Måler nøkkelparameteren (typisk effekt) på det berørte utstyret; øvrige parametere (typisk
driftstimer) **estimeres**. Fordi driftstimene er stipulert — ikke fullmålt — er den forsvarlige
verifiserte besparelsen **mer konservativ** enn en generisk øvre grense: en andel av tiltakets
kostnad som ligger UNDER den generiske policy-taket. Denne strammere, metode-spesifikke grensen er
kodifisert som en egen validator-stage (`validator._ENERGY_METHOD_MAX_FRACTION`), uavhengig av det
generiske P90-taket.

View file

@ -37,6 +37,15 @@ MAX_SAVING_FRACTION = 0.30
"""Policy cap: at most 30% of an affected item's cost is realistically recoverable as a
saving. The LP bounds the feasible saving by this fraction."""
_ENERGY_METHOD_MEASURE = "energy_efficiency"
_ENERGY_METHOD_MAX_FRACTION = 0.15
"""Step 9 (SC7-B): the IPMVP Option A method-specific cap. Option A measures only the KEY parameter
and STIPULATES the rest (operating hours), so the defensibly-verifiable saving is more conservative
than the generic policy cap deliberately STRICTER than ``MAX_SAVING_FRACTION`` so this rule is an
INDEPENDENT gate: it can reject a proposal the generic P90 stage passes (not redundant). The concrete
fraction is calibrated against the reference domain; the CONDITION (a method-scoped stricter cap) is
the encoded rule. Returns the same ``Rejection`` type a validator stage, not a new gate."""
_MC_SAMPLES = 512
_MC_SEED = 20260624
@ -126,6 +135,21 @@ def validate_proposal(proposal: SavingsProposal) -> ValidatedProposal | Rejectio
proposal=proposal,
reason=f"claimed saving {proposal.claimed_saving_nok:.0f} exceeds P90 feasible {p90:.0f}",
)
# Stage 5 (Step 9, SC7-B): a method-specific rule STRICTER than the generic cap. A proposal in
# the energy method (IPMVP Option A) must clear a lower, method-scoped feasible — an INDEPENDENT
# gate that can reject a proposal the P90 stage passed. Same ``Rejection`` type, not a new gate.
if proposal.measure == _ENERGY_METHOD_MEASURE:
method_feasible = _ENERGY_METHOD_MAX_FRACTION * sum(
it.total for it in proposal.affected_items
)
if proposal.claimed_saving_nok > method_feasible:
return Rejection(
proposal=proposal,
reason=(
f"claimed {proposal.claimed_saving_nok:.0f} exceeds the {_ENERGY_METHOD_MEASURE} "
f"method cap {method_feasible:.0f} (stricter than the generic P90)"
),
)
return ValidatedProposal(proposal=proposal, p10=p10, p50=p50, p90=p90, nominal_feasible=nominal)

View file

@ -0,0 +1,99 @@
"""Step 9 load-bearing seam (SC7): assessment-method encoding — a PERSISTED dimension-scoped
example (Arm A) + an INDEPENDENT deterministic validator rule (Arm B).
Arm A (navigable, dimension-scoped): the persisted, MAF-repo-local method bundle
(``data/method_examples/energi/``) carries a ``type: methodology`` file with ``dimension: energi``.
Its sentinel reaches ``bundle_context(..., dimension="energi")`` but is ABSENT at
``dimension="asfalt"`` the detach is the Step-3 dimension filter (H4: "text is rendered" is not a
detachable seam on its own; the dimension SCOPING is).
Arm B (independent validator rule): a proposal the generic P90 stage PASSES but the stricter energy-
method cap REJECTS proving the method rule is an INDEPENDENT gate (not redundant with P90). Detach
the rule (or flip the measure) and the same proposal validates. The rule returns the validator's own
``Rejection`` type, so the validator stays the sole blocking gate and provenance stays honest (the
numbers only).
Patterns: ``test_okf.py:141/51`` (navigable + bundle_context), ``test_validator.py:49`` (rule).
"""
from __future__ import annotations
from pathlib import Path
from portfolio_optimiser import okf
from portfolio_optimiser.ir import AffectedItem, SavingsProposal
from portfolio_optimiser.validator import (
Rejection,
ValidatedProposal,
validate_proposal,
)
METHOD_BUNDLE = (
Path(__file__).resolve().parents[1]
/ "src"
/ "portfolio_optimiser"
/ "data"
/ "method_examples"
/ "energi"
)
_METHOD_SENTINEL = "ENERGI-METODE-IPMVP-A-SENTINEL"
# --- Arm A: navigable, dimension-scoped persisted example ----------------------------------------
def test_persisted_method_navigable_when_dimension_matches() -> None:
"""The persisted energi method (``dimension: energi``) is navigable + rendered when the run is
scoped to energi an actually-delivered, persisted '>=1 encoded method' (not an ephemeral copy)."""
bundle = okf.navigate_bundle(str(METHOD_BUNDLE))
scoped = okf.bundle_context(bundle, dimension="energi")
assert _METHOD_SENTINEL in scoped
def test_persisted_method_absent_for_other_dimension() -> None:
"""LOAD-BEARING (SC7-A): the energi method is ABSENT when the run is scoped to another dimension.
RED if the Step-3 dimension filter is detached (the energi method then leaks into an asfalt run)."""
bundle = okf.navigate_bundle(str(METHOD_BUNDLE))
other = okf.bundle_context(bundle, dimension="asfalt")
assert _METHOD_SENTINEL not in other
# --- Arm B: independent deterministic validator rule ---------------------------------------------
def _proposal(measure: str, claimed: float) -> SavingsProposal:
"""Affected total 100000 (degenerate MC, empty assumptions) -> generic P90 = 0.30 x 100000 =
30000; the energy-method cap = 0.15 x 100000 = 15000."""
return SavingsProposal(
project_id="P-ENERGI",
measure=measure,
affected_items=[AffectedItem(code="ENERGI-TOTAL-EL", quantity=100000, unit_cost=1.0)],
claimed_saving_nok=claimed,
assumptions={},
)
def test_method_rule_is_independent_of_generic_p90() -> None:
"""LOAD-BEARING (SC7-B): claimed 20000 is <= generic P90 (30000) but > the energy-method cap
(15000). A NON-energy proposal with the same numbers validates (P90 passes); the energy-method
proposal is REJECTED by the stricter method rule an independent flip. RED if the method rule is
detached (the energy proposal then validates too)."""
# Control: same numbers, non-energy measure -> the generic P90 stage validates it.
control = validate_proposal(_proposal("scope_reduction", 20000))
assert isinstance(control, ValidatedProposal), (
"the generic P90 stage should pass 20000 <= 30000"
)
# Energy method: the stricter method cap flips the SAME numbers to a Rejection.
result = validate_proposal(_proposal("energy_efficiency", 20000))
assert isinstance(result, Rejection), "the energy-method cap must reject a P90-valid over-claim"
assert "method cap" in result.reason
# The rule returns the validator's OWN Rejection type -> the validator stays the sole blocking
# gate (numbers only); it never introduces a new gate type.
def test_method_rule_admits_within_the_stricter_cap() -> None:
"""Causality control: an energy proposal WITHIN the method cap (claimed 15000 == 0.15 x 100000)
validates proving the rejection above is caused by exceeding the cap, not the measure string."""
result = validate_proposal(_proposal("energy_efficiency", 15000))
assert isinstance(result, ValidatedProposal)