feat(fase1): assessment-method encoding — persisted dimension-scoped example + validator rule (F1)
This commit is contained in:
parent
16b6d80b82
commit
69ca508677
4 changed files with 171 additions and 0 deletions
19
src/portfolio_optimiser/data/method_examples/energi/index.md
Normal file
19
src/portfolio_optimiser/data/method_examples/energi/index.md
Normal 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)
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
"""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."""
|
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_SAMPLES = 512
|
||||||
_MC_SEED = 20260624
|
_MC_SEED = 20260624
|
||||||
|
|
||||||
|
|
@ -126,6 +135,21 @@ def validate_proposal(proposal: SavingsProposal) -> ValidatedProposal | Rejectio
|
||||||
proposal=proposal,
|
proposal=proposal,
|
||||||
reason=f"claimed saving {proposal.claimed_saving_nok:.0f} exceeds P90 feasible {p90:.0f}",
|
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)
|
return ValidatedProposal(proposal=proposal, p10=p10, p50=p50, p90=p90, nominal_feasible=nominal)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
99
tests/test_assessment_method_loadbearing.py
Normal file
99
tests/test_assessment_method_loadbearing.py
Normal 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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue