Skip to content

NC-METH-001 v1.1 — Annex I: pytest Acceptance Test Suite Specification

18 Tests across 5 Categories · Closes AUDIT-031 + AUDIT-034

12 May 2026 · Internal · Author: AI-assisted, S. Rubinyi review · Implementation per WP3


I.0 Overview

Annex I closes AUDIT-031 (anomaly detection / QA framework) and AUDIT-034 (no acceptance test suite) by specifying a pytest-based acceptance test suite for the methodology.

The suite is the methodology's continuous-integration safety net. Every change to the methodology, the LC model, or downstream segment registers triggers the suite; failures block the version lock per Part F § F.2.

I.0.1 What this annex is

A specification — natural-language description of each test, with pseudocode and pass/fail criteria. The actual Python implementation (pytest files in methodology-package/tests/) is the WP3 deliverable per Part F § F.2.5.

This split is deliberate. Specifying tests today lets Marc and Steven review the test logic before code is written; the implementation in WP3 then converts the specification to executable Python.

I.0.2 What this annex is NOT

  • Not the implementation. Python code is [PENDING WP3 — Weeks 5–8]
  • Not the test runner CI. GitHub Actions or equivalent CI is v1.2 deliverable
  • Not test coverage beyond the v1.1.0 lock scope. Additional tests (yield model unit tests, OPEX model unit tests) deferred to v1.2

I.0.3 Test suite categories (per Part F § F.2.2)

Category Test count Closes
Replication tests 5 AUDIT-031 (anomaly detection on actual outputs)
Cross-estate ranking 2 AUDIT-031
High-materiality findings 4 AUDIT-034 (verifies the 3 ACTIVE-IRR + Annex H gating)
Governance 5 AUDIT-034
Cross-reference integrity 2 AUDIT-031
Total 18 tests

I.0.4 Pass/fail criteria per version bump (per Part F § F.2.4)

Version type Test suite requirement
PATCH (v1.1.0 → v1.1.1) Advisory pass; failures documented but not blocking
MINOR (v1.1.0 → v1.2.0) All tests pass; blocking for lock
MAJOR (v1.x.y → v2.0.0) All tests pass + re-validation of all estate IC papers; blocking

I.1 Test framework architecture

I.1.1 Language and framework

  • Language: Python 3.11+
  • Framework: pytest (industry standard; rich fixtures, parametrisation, reporting)
  • Dependencies: pandas (segment register reads), openpyxl (LC model reads), pyyaml (methodology canonical YAML), pathlib (file resolution)

I.1.2 Repository location

nc-methodology/tests/
├── conftest.py              # shared fixtures
├── test_replication.py      # 5 tests (Section I.3)
├── test_cross_estate.py     # 2 tests (Section I.4)
├── test_audit_findings.py   # 4 tests (Section I.5)
├── test_governance.py       # 5 tests (Section I.6)
├── test_xref.py             # 2 tests (Section I.7)
└── fixtures/
    ├── lc_v1_register.csv
    ├── methodology_canonical.yaml
    ├── changelog_v1_1.md
    └── deprecated_params.yaml

I.1.3 Execution

Trigger Action
Local development pytest tests/ from repo root
Pre-commit hook All tests run before any commit to main (WP3 sets up via pre-commit Python package)
CI (v1.2 deliverable) GitHub Actions on push to any branch

I.1.4 Test naming convention

test_{category}_{specific_assertion}.py::test_{specific_function}

Examples: test_replication.py::test_lc_irr_within_methodology, test_governance.py::test_semver_tag_present.


I.2 Test inventory

The 18 tests at v1.1.0:

# Test Category Closes Status
1 test_lc_irr_within_methodology Replication AUDIT-031 [PENDING WP3]
2 test_lc_dscr_p90_compliant Replication AUDIT-031 [PENDING WP3]
3 test_bp_irr_within_methodology Replication AUDIT-031 [PENDING WP3]
4 test_lc_envelope_capacity_matches_register Replication AUDIT-031 [PENDING WP3]
5 test_lc_capex_matches_register Replication AUDIT-031 [PENDING WP3]
6 test_estate_ranking_stable_across_cost_bands Cross-estate AUDIT-031 [PENDING WP3]
7 test_top5_estates_stable_p90 Cross-estate AUDIT-031 [PENDING WP3]
8 test_audit_001_yield_convention_documented High-materiality AUDIT-034 [PENDING WP3]
9 test_audit_014_debt_cost_aligned High-materiality AUDIT-034 [PENDING WP3]
10 test_audit_016_opex_aligned High-materiality AUDIT-034 [PENDING WP3]
11 test_audit_040_methodology_canonical_present High-materiality AUDIT-034 [PENDING WP3]
12 test_semver_tag_present Governance AUDIT-034 [PENDING WP3]
13 test_changelog_entry_for_current_version Governance AUDIT-034 [PENDING WP3]
14 test_deprecated_parameters_absent Governance AUDIT-034 [PENDING WP3]
15 test_v3_segment_ids_absent Governance AUDIT-034 [PENDING WP3]
16 test_methodology_package_2_retired Governance AUDIT-034 [PENDING WP3]
17 test_part_b_to_c_references_resolve Cross-reference AUDIT-031 [PENDING WP3]
18 test_part_d_to_c_references_resolve Cross-reference AUDIT-031 [PENDING WP3]

I.3 Replication tests (5 tests)

Replication tests verify that running the methodology end-to-end against known inputs produces known outputs. They are the highest-confidence test category because they exercise the full pipeline.

I.3.1 test_lc_irr_within_methodology

What it asserts: LC sponsor IRR at 52% LTV produced by methodology-aligned inputs falls within ±50 bps of the IC paper canonical 12.2%.

Pseudocode:

def test_lc_irr_within_methodology():
    """LC sponsor IRR at 52% LTV should match IC canonical ±50 bps."""
    # Load LC v1.0 register
    register = load_segment_register("fixtures/lc_v1_register.csv")
    # Apply methodology canonical parameters
    inputs = {
        "yield_kwh_per_kwp_per_yr": 1485,  # methodology RES-001; or 1380 after AUDIT-001 reconciliation
        "tariff_thb_per_kwh": 3.85,         # locked
        "tariff_escalation": 0.0,            # LC flat, no escalation
        "fx_thb_per_usd": 35,                # main program
        "debt_cost": 0.0575,                 # EXIM 5.75%
        "debt_tenor_yrs": 12,
        "ltv": 0.52,
        "dscr_target": 1.05,
        "opex_per_typology": load_opex_canonical(),
        "contract_tenor_yrs": 25,
    }
    # Solve for lender-sized debt
    debt = solve_debt_size(inputs, register)
    # Compute sponsor IRR
    irr = compute_sponsor_irr(register, inputs, debt)
    # Assert within ±50 bps of canonical
    assert 11.7 <= irr <= 12.7, (
        f"LC IRR {irr:.2%} outside ±50 bps of canonical 12.2%. "
        f"Check yield convention (AUDIT-001) or O&M build (AUDIT-016)."
    )

Pass/fail: - PASS: 11.7% ≤ IRR ≤ 12.7% - FAIL: IRR outside band → investigate AUDIT-001 / AUDIT-014 / AUDIT-016 closures

Closes: AUDIT-031 (detects methodology output drift)

I.3.2 test_lc_dscr_p90_compliant

What it asserts: LC minimum DSCR over the 12-year debt tenor at P90 yield stays above the lender threshold 1.05×.

Pseudocode:

def test_lc_dscr_p90_compliant():
    """LC minimum DSCR at P90 yield must be ≥1.05×."""
    register = load_segment_register("fixtures/lc_v1_register.csv")
    inputs = methodology_canonical_inputs("LC")
    inputs["yield_kwh_per_kwp_per_yr"] = 1380 * 0.92  # P90 (8% degradation of P50)
    debt = solve_debt_size(inputs, register)
    dscr_curve = compute_dscr_curve(register, inputs, debt, years=12)
    min_dscr = min(dscr_curve)
    assert min_dscr >= 1.05, (
        f"LC min DSCR at P90 = {min_dscr:.2f}× — below lender threshold. "
        f"Possible debt sizing error or yield convention mismatch."
    )

Pass/fail: - PASS: min DSCR ≥ 1.05× - FAIL: triggers re-examination of debt sizing logic or P90 derivation

Closes: AUDIT-031

I.3.3 test_bp_irr_within_methodology

What it asserts: Bangpoo (BP) sponsor IRR at methodology-canonical inputs falls within expected range (per BP investment thesis, ±50 bps).

Pseudocode:

def test_bp_irr_within_methodology():
    """BP sponsor IRR at methodology canonical inputs must match BP IC ±50 bps."""
    register = load_segment_register("fixtures/bp_v6_3_register.csv")  # placeholder
    inputs = methodology_canonical_inputs("BP")  # includes BESS, carbon
    debt = solve_debt_size(inputs, register)
    irr = compute_sponsor_irr(register, inputs, debt)
    # BP target IRR [PENDING — BP IC not yet locked at v1.1.0 lock]
    # Use ±100 bps band until BP IC locks
    target_low, target_high = (11.5, 13.5)
    assert target_low <= irr <= target_high, (
        f"BP IRR {irr:.2%} outside acceptable band [{target_low}%, {target_high}%]"
    )

Pass/fail: - PASS: IRR within band - FAIL: BP methodology application diverges; investigate - Note: BP register fixtures/bp_v6_3_register.csv is [PENDING WP3]; test stub at v1.1.0 lock; full assertion when BP IC locks

Closes: AUDIT-031

I.3.4 test_lc_envelope_capacity_matches_register

What it asserts: LC envelope total capacity (MWp) computed from segment register matches v1.0 canonical 34.59 MWp ±0.5%.

Pseudocode:

def test_lc_envelope_capacity_matches_register():
    """LC envelope MWp from register must match canonical 34.59 ±0.5%."""
    register = load_segment_register("fixtures/lc_v1_register.csv")
    total_mwp = register["mwp"].sum()
    assert abs(total_mwp - 34.59) / 34.59 < 0.005, (
        f"LC envelope from register = {total_mwp:.2f} MWp; canonical = 34.59 MWp. "
        f"Mismatch > 0.5% indicates register tampering or segment ID drift."
    )

Pass/fail: - PASS: |total − 34.59| / 34.59 < 0.005 - FAIL: register or canonical broken

Closes: AUDIT-031

I.3.5 test_lc_capex_matches_register

What it asserts: LC total CAPEX (USD) computed from segment register matches v1.0 canonical $25.04M EPC ±0.5%.

Pseudocode:

def test_lc_capex_matches_register():
    """LC total EPC from register must match canonical $25.04M ±0.5%."""
    register = load_segment_register("fixtures/lc_v1_register.csv")
    total_epc_usd = register["epc_usd"].sum()
    assert abs(total_epc_usd - 25.04e6) / 25.04e6 < 0.005, (
        f"LC EPC from register = ${total_epc_usd/1e6:.2f}M; canonical = $25.04M. "
        f"Mismatch > 0.5% indicates per-segment cost mutation or sum error."
    )

Pass/fail: - PASS: |total − $25.04M| / $25.04M < 0.005 - FAIL: register cost build broken

Closes: AUDIT-031


I.4 Cross-estate ranking tests (2 tests)

Cross-estate tests verify that the methodology produces stable estate rankings across reasonable parameter uncertainty bands.

I.4.1 test_estate_ranking_stable_across_cost_bands

What it asserts: Top-5 estates by post-mitigation leveraged IRR are stable across the methodology cost band uncertainty ($700–1,100/kWp range for solar).

Pseudocode:

def test_estate_ranking_stable_across_cost_bands():
    """Top-5 estates by leveraged IRR must be stable across cost band uncertainty."""
    estates = ["LC", "BP", "MTP", "Lat_Krabang", "MTP_Port", "Smart_Park",
               "Map_Ta_Phut", "Lamphun", "Phichit", "Songkhla", 
               "Bang_Chan", "Nakhon_Luang", "Kaeng_Khoi"]
    # Three cost scenarios per estate
    rankings = {}
    for cost_scenario in ["low_700", "mid_900", "high_1100"]:
        rankings[cost_scenario] = rank_estates_by_irr(estates, cost_scenario)
    # Take top-5 from each scenario
    top5_low = set(rankings["low_700"][:5])
    top5_mid = set(rankings["mid_900"][:5])
    top5_high = set(rankings["high_1100"][:5])
    # Overlap must be ≥ 4 of 5 across all three scenarios
    common = top5_low & top5_mid & top5_high
    assert len(common) >= 4, (
        f"Top-5 estate ranking unstable across cost bands: {common}. "
        f"Methodology may be producing rank-flips under reasonable uncertainty."
    )

Pass/fail: - PASS: ≥4 of top-5 are stable across low/mid/high cost scenarios - FAIL: ranking is fragile; methodology produces rank-flips under cost band uncertainty

Closes: AUDIT-031 (anomaly detection on cross-estate ranking)

I.4.2 test_top5_estates_stable_p90

What it asserts: Top-5 estates by P50 IRR are similar to top-5 by P90 IRR (cohort stability under yield risk).

Pseudocode:

def test_top5_estates_stable_p90():
    """Top-5 estates by P50 IRR vs P90 IRR — overlap must be ≥4 of 5."""
    estates = [...]  # 13 IEAT estates
    top5_p50 = set(rank_estates_by_irr(estates, "p50")[:5])
    top5_p90 = set(rank_estates_by_irr(estates, "p90")[:5])
    common = top5_p50 & top5_p90
    assert len(common) >= 4, (
        f"Top-5 estate ranking unstable from P50 → P90: {common}. "
        f"Yield risk is creating cohort instability — investigate methodology yield treatment."
    )

Pass/fail: - PASS: ≥4 of 5 estates remain in top-5 under P90 stress - FAIL: methodology yield treatment creates excessive cross-estate noise

Closes: AUDIT-031


I.5 High-materiality findings tests (4 tests)

These tests verify that the methodology v1.1.0 lock correctly reflects the closure of the 3 ACTIVE-IRR findings + the Annex H gating event.

I.5.1 test_audit_001_yield_convention_documented

What it asserts: The methodology canonical yields document the P50 convention used (AC delivery vs DC generation; pre/post soiling; pre/post Y1 degradation).

Pseudocode:

def test_audit_001_yield_convention_documented():
    """AUDIT-001 closure: yield convention must be documented in methodology canonical YAML."""
    canonical = load_methodology_canonical("fixtures/methodology_canonical.yaml")
    yield_record = canonical["yield"]
    required_keys = ["p50_kwh_per_kwp_per_yr", "convention_basis", 
                      "convention_soiling", "convention_degradation_y1"]
    for key in required_keys:
        assert key in yield_record, (
            f"Yield convention key '{key}' missing. AUDIT-001 not closed."
        )
    # Convention basis must be explicit
    valid_bases = ["AC_delivery", "DC_generation_pre_inverter"]
    assert yield_record["convention_basis"] in valid_bases

Pass/fail: - PASS: all yield convention keys present and valid - FAIL: AUDIT-001 not closed; yield convention not documented

Closes: AUDIT-034 (verifies AUDIT-001 has tangible closure artefact)

I.5.2 test_audit_014_debt_cost_aligned

What it asserts: LC model debt cost matches methodology canonical 5.75% EXIM (or documents why blended 6.0% applies).

Pseudocode:

def test_audit_014_debt_cost_aligned():
    """AUDIT-014 closure: LC model debt cost must align with methodology canonical."""
    canonical_debt_cost = load_methodology_canonical("fixtures/methodology_canonical.yaml")["debt"]["cost_pct"]
    lc_model_debt_cost = read_lc_model_inputs("fixtures/lc_model_v1.xlsx")["debt_cost"]
    # Allow either exact alignment OR documented exception
    if not (canonical_debt_cost - 0.001 <= lc_model_debt_cost <= canonical_debt_cost + 0.001):
        # Documented exception required
        exception_doc = read_lc_model_assumptions_tab("fixtures/lc_model_v1.xlsx")
        assert "debt_cost_blended_rationale" in exception_doc, (
            f"LC model debt cost {lc_model_debt_cost:.2%} ≠ canonical {canonical_debt_cost:.2%}. "
            f"AUDIT-014 not closed; either align or document blended rationale."
        )

Pass/fail: - PASS: aligned, OR documented exception - FAIL: misaligned and no documentation

Closes: AUDIT-034 (verifies AUDIT-014 closure)

I.5.3 test_audit_016_opex_aligned

What it asserts: LC model OPEX aligns with methodology canonical per-typology O&M build (per AUDIT-016 closure).

Pseudocode:

def test_audit_016_opex_aligned():
    """AUDIT-016 closure: LC model OPEX must use per-typology methodology canonical."""
    canonical = load_methodology_canonical("fixtures/methodology_canonical.yaml")["opex"]
    lc_model = read_lc_model_opex_tab("fixtures/lc_model_v1.xlsx")
    # Verify model uses per-typology values, not flat
    typologies = ["T1", "T2", "T4A", "T4B_DC", "T6W"]
    for typology in typologies:
        assert typology in lc_model, (
            f"LC model OPEX missing typology '{typology}'. AUDIT-016 not closed (still flat OPEX)."
        )
        canonical_value = canonical[typology]["o_and_m_usd_per_kwp_per_yr"]
        model_value = lc_model[typology]["o_and_m_usd_per_kwp_per_yr"]
        assert abs(canonical_value - model_value) < 0.01 * canonical_value, (
            f"LC model OPEX for {typology} = {model_value} vs canonical {canonical_value}. "
            f"AUDIT-016 closure incomplete."
        )

Pass/fail: - PASS: model uses per-typology, values match canonical - FAIL: model still has flat $20/kWp/yr or per-typology values misaligned

Closes: AUDIT-034 (verifies AUDIT-016 closure)

I.5.4 test_audit_040_methodology_canonical_present

What it asserts: Methodology canonical CAPEX per typology (Annex H closure) exists in the canonical YAML and has WP2-validated values.

Pseudocode:

def test_audit_040_methodology_canonical_present():
    """AUDIT-040 closure: Annex H methodology canonical CAPEX per typology must exist."""
    canonical = load_methodology_canonical("fixtures/methodology_canonical.yaml")
    capex_canonical = canonical.get("capex", {})
    typologies = ["T1", "T2", "T4A", "T4B_DC", "T6W"]
    for typology in typologies:
        assert typology in capex_canonical, (
            f"Methodology canonical CAPEX missing for '{typology}'. "
            f"Annex H / AUDIT-040 not closed."
        )
        # WP2 validation flag must be present
        record = capex_canonical[typology]
        assert record.get("wp2_validated", False), (
            f"Typology '{typology}' methodology canonical not WP2-validated. "
            f"Annex H closure incomplete."
        )
        # Confidence interval must be specified
        assert "confidence_interval_usd_per_kwp" in record

Pass/fail: - PASS: all 5 typologies have WP2-validated canonical values with confidence intervals - FAIL: Annex H closure incomplete (gating event for v1.1.0 lock)

Closes: AUDIT-034 (verifies AUDIT-040 / Annex H closure — gating)


I.6 Governance tests (5 tests)

Governance tests verify that Part F change-control discipline is being followed.

I.6.1 test_semver_tag_present

What it asserts: Git repo has a valid semver tag matching the methodology canonical YAML version field.

Pseudocode:

def test_semver_tag_present():
    """Methodology canonical version must match a Git tag."""
    canonical = load_methodology_canonical("fixtures/methodology_canonical.yaml")
    version = canonical["meta"]["version"]
    # Verify version is valid semver
    import re
    assert re.match(r"^\d+\.\d+\.\d+(-[a-zA-Z0-9]+)?$", version), (
        f"Version '{version}' is not valid semver."
    )
    # Verify Git tag exists
    git_tags = get_git_tags()  # subprocess call to `git tag -l`
    expected_tag = f"v{version}"
    assert expected_tag in git_tags, (
        f"Methodology version '{version}' has no matching Git tag '{expected_tag}'."
    )

Pass/fail: - PASS: version is valid semver AND Git tag exists - FAIL: missing or mismatched

Closes: AUDIT-034 (verifies semver discipline per Part F § F.1)

I.6.2 test_changelog_entry_for_current_version

What it asserts: CHANGELOG.md has an entry for the current methodology version per Part F § F.1.3.

Pseudocode:

def test_changelog_entry_for_current_version():
    """CHANGELOG.md must have an entry for the current methodology version."""
    canonical = load_methodology_canonical("fixtures/methodology_canonical.yaml")
    version = canonical["meta"]["version"]
    changelog_content = open("fixtures/CHANGELOG.md").read()
    expected_heading = f"## v{version}"
    assert expected_heading in changelog_content, (
        f"CHANGELOG.md missing entry '{expected_heading}'. "
        f"Per Part F § F.1.3, every version must have a change-log entry."
    )
    # Entry must have minimum required fields per Part F § F.1.3
    entry = extract_changelog_entry(changelog_content, version)
    required_fields = ["Date locked", "Approver(s)", "Changes", "IRR impact summary", "Closed register items"]
    for field in required_fields:
        assert field in entry, (
            f"Changelog entry for v{version} missing '{field}'."
        )

Pass/fail: - PASS: entry exists with all required fields - FAIL: missing entry or incomplete

Closes: AUDIT-034 (verifies Part F § F.1.3 compliance)

I.6.3 test_deprecated_parameters_absent

What it asserts: Deprecated parameters (per Part F § F.6.3) are not used in active artefacts.

Pseudocode:

def test_deprecated_parameters_absent():
    """Deprecated parameters must not be referenced in active artefacts."""
    deprecated = load_deprecated_params("fixtures/deprecated_params.yaml")
    # Check LC model
    lc_model_assumptions = read_lc_model_assumptions_tab("fixtures/lc_model_v1.xlsx")
    for param, deprecation_record in deprecated.items():
        if deprecation_record["status"] == "retired":
            # Hard error if retired param appears
            assert param not in lc_model_assumptions, (
                f"Retired parameter '{param}' still in LC model. "
                f"Per Part F § F.6.4, retired params must be removed."
            )
        elif deprecation_record["status"] == "deprecated":
            # Warning logged but not failing
            if param in lc_model_assumptions:
                pytest.warns(DeprecationWarning, match=param)

Pass/fail: - PASS: no retired parameters in active artefacts; deprecated parameters generate warnings - FAIL: retired parameter found in active artefact (e.g., FX 32 unqualified, BESS $400, ITMO solar pricing)

Closes: AUDIT-034 (enforces Part F § F.6 deprecation discipline)

I.6.4 test_v3_segment_ids_absent

What it asserts: V3 segment IDs (deprecated per Part F § F.5.1) do not appear in active artefacts.

Pseudocode:

def test_v3_segment_ids_absent():
    """V3 segment IDs (LC-S-V3-XXXX format) must not appear in active artefacts."""
    import re
    v3_pattern = re.compile(r"[A-Z]{2,4}-S-V3-\d{4}")
    # Check LC v1.0 register
    register_content = open("fixtures/lc_v1_register.csv").read()
    v3_matches = v3_pattern.findall(register_content)
    assert len(v3_matches) == 0, (
        f"V3 segment IDs found in LC v1.0 register: {v3_matches}. "
        f"Per Part F § F.5.1, V3 IDs are retired in v1.1; v1.0 register IDs are canonical."
    )
    # Check methodology canonical YAML
    canonical_content = open("fixtures/methodology_canonical.yaml").read()
    assert len(v3_pattern.findall(canonical_content)) == 0, "V3 IDs in methodology canonical"

Pass/fail: - PASS: no V3 IDs anywhere - FAIL: V3 ID found → segment ID consolidation (AUDIT-044) regression

Closes: AUDIT-034 (enforces AUDIT-044 closure)

I.6.5 test_methodology_package_2_retired

What it asserts: The deprecated methodology-package 2 Drive folder is retired (not referenced in any active artefact).

Pseudocode:

def test_methodology_package_2_retired():
    """The deprecated `methodology-package 2` folder must not be referenced in active artefacts."""
    forbidden_reference = "methodology-package 2"
    # Scan all .md files in repo
    repo_root = "."
    md_files = find_files_by_extension(repo_root, ".md")
    violations = []
    for md_file in md_files:
        content = open(md_file).read()
        if forbidden_reference in content:
            # Allow reference in deprecation notice / Part F § F.6.3 / Part F § F.7.5
            allowed_contexts = ["DEPRECATED", "F.6.3", "F.7.5", "RETIRED"]
            line_with_ref = next(line for line in content.split("\n") if forbidden_reference in line)
            if not any(ctx in line_with_ref for ctx in allowed_contexts):
                violations.append(f"{md_file}: {line_with_ref}")
    assert len(violations) == 0, (
        f"Active references to deprecated 'methodology-package 2': {violations}. "
        f"Per Part F § F.7.5, this folder is retired."
    )

Pass/fail: - PASS: no active references; only deprecation notices reference the folder - FAIL: active reference found → AUDIT-032 regression

Closes: AUDIT-034 (enforces AUDIT-032 closure)


I.7 Cross-reference integrity tests (2 tests)

Cross-reference tests verify that methodology document cross-references resolve correctly.

I.7.1 test_part_b_to_c_references_resolve

What it asserts: All references from Part B (pipeline) to Part C (typologies) resolve to actual sections in Part C.

Pseudocode:

def test_part_b_to_c_references_resolve():
    """All Part B references to Part C must resolve."""
    import re
    part_b_content = open("parts/B_pipeline.md").read()
    part_c_content = open("parts/C_typologies.md").read()
    # Extract all Part C references from Part B
    pattern = re.compile(r"Part C § (C\.\d+(?:\.\d+)*)")
    references = set(pattern.findall(part_b_content))
    # For each reference, verify section heading exists in Part C
    for ref in references:
        heading_pattern = re.compile(rf"^#+ {re.escape(ref)}", re.MULTILINE)
        assert heading_pattern.search(part_c_content), (
            f"Part B references Part C § {ref}, but section not found in Part C."
        )

Pass/fail: - PASS: all references resolve - FAIL: dangling reference → cross-reference drift

Closes: AUDIT-031

I.7.2 test_part_d_to_c_references_resolve

What it asserts: All references from Part D (cross-cutting) to Part C resolve.

Pseudocode:

def test_part_d_to_c_references_resolve():
    """All Part D references to Part C must resolve."""
    # Same logic as I.7.1 but Part D → Part C
    import re
    part_d_content = open("parts/D_crosscutting.md").read()
    part_c_content = open("parts/C_typologies.md").read()
    pattern = re.compile(r"Part C § (C\.\d+(?:\.\d+)*)")
    references = set(pattern.findall(part_d_content))
    for ref in references:
        heading_pattern = re.compile(rf"^#+ {re.escape(ref)}", re.MULTILINE)
        assert heading_pattern.search(part_c_content), (
            f"Part D references Part C § {ref}, but section not found in Part C."
        )

Pass/fail: - PASS: all references resolve - FAIL: dangling reference

Closes: AUDIT-031


I.8 Fixtures

The test suite depends on several fixtures providing canonical inputs:

I.8.1 fixtures/lc_v1_register.csv

LC v1.0 investment segment register (NC-IS-LC-001). Read-only fixture; tests assert against canonical values.

[PENDING WP3] — must be exported from canonical Drive file 1JhPpY3z_JlbHnIxYsOJQ-AzAVwgnk1ow and committed to test fixtures.

I.8.2 fixtures/methodology_canonical.yaml

Methodology canonical YAML format:

meta:
  version: "1.1.0"
  date_locked: "2026-07-22"
  approvers: ["M. Forni", "S. Rubinyi"]
yield:
  p50_kwh_per_kwp_per_yr: 1485
  convention_basis: "AC_delivery"
  convention_soiling: "post-soiling"
  convention_degradation_y1: "pre-Y1"
debt:
  cost_pct: 0.0575
  tenor_yrs: 12
opex:
  T1:
    o_and_m_usd_per_kwp_per_yr: 28
    cleaning_frequency: "quarterly"
  T6W:
    o_and_m_usd_per_kwp_per_yr: 45
    cleaning_frequency: "monthly"
  # ... other typologies
capex:
  T1:
    usd_per_kwp: [PENDING WP2]
    confidence_interval_usd_per_kwp: [PENDING WP2]
    wp2_validated: false  # toggles true after WP2
  # ... other typologies

[PENDING WP2 + WP3] — values gated by WP2 engineering review.

I.8.3 fixtures/CHANGELOG.md

CHANGELOG mirror with v1.0 and v1.1.0 entries.

I.8.4 fixtures/deprecated_params.yaml

Deprecated parameters per Part F § F.6.3:

FX_32_THB_USD_unqualified:
  status: deprecated
  replaced_by: "FX 35 main / 32 IET-only"
  retire_after: "v1.2"
BESS_CAPEX_400_per_kWh:
  status: deprecated
  replaced_by: "$175/kWh + thermal premium"
  retire_after: "v1.2"
# ... 9 deprecations total

I.8.5 fixtures/lc_model_v1.xlsx

LC v1.0 financial model export. Required for tests I.3.1, I.3.2, I.5.2, I.5.3, I.6.3.

[PENDING WP3] — export from canonical model with PII redacted.


I.9 CI / execution discipline

I.9.1 Local execution (v1.1.0 lock)

cd nc-methodology
pytest tests/ -v --tb=short

Expected output at v1.1.0 lock: all 18 tests PASS.

I.9.2 Pre-commit hook

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: methodology-tests
        name: Methodology acceptance tests
        entry: pytest tests/
        language: system
        pass_filenames: false
        always_run: true

I.9.3 CI pipeline (v1.2 deliverable)

GitHub Actions equivalent. Per Part F § F.7.2:

# .github/workflows/test.yml
name: Methodology Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-python@v4
        with:
          python-version: "3.11"
      - run: pip install -r requirements.txt
      - run: pytest tests/ --junit-xml=test_report.xml
      - uses: actions/upload-artifact@v3
        with:
          name: test-report
          path: test_report.xml

[PENDING v1.2] — CI infrastructure setup.


I.10 Test artefacts

Per Part F § F.2.5, the test suite produces three artefacts:

I.10.1 test_report.html

Full pytest results with timestamps, pass/fail status, error tracebacks. Committed to Git in tests/_reports/ on each test run; archived per version lock.

I.10.2 test_coverage.json

Coverage by methodology section (which Parts/Annexes are exercised by tests):

{
  "version": "1.1.0",
  "coverage": {
    "Part_A_framing": 0.85,
    "Part_B_pipeline": 0.90,
    "Part_C_typologies": 0.75,
    "Part_D_crosscutting": 0.80,
    "Part_E_runbook": 0.60,
    "Part_F_governance": 0.95,
    "Annex_G_credit": 0.50,
    "Annex_H_cost_drivers": 0.65,
    "Annex_I_pytest": 1.00,
    "Annex_J_audit_register": 0.40
  }
}

Methodology sections under 0.50 coverage flagged for v1.2 test expansion.

I.10.3 regression_log.csv

Historical pass/fail tracking across versions:

version,test_name,status,timestamp,error_message
1.1.0,test_lc_irr_within_methodology,PASS,2026-07-22T10:00:00Z,
1.1.0,test_audit_040_methodology_canonical_present,PASS,2026-07-22T10:00:01Z,
...

Regression log allows comparison across versions: if a test passes at v1.1.0 but fails at v1.1.1, the diff is immediate.


I.11 Acknowledged Annex I gaps

  1. Python implementation pending WP3 — Annex I specifies the test logic; actual code is WP3 deliverable (Weeks 5–8). All 18 tests show [PENDING WP3 implementation].
  2. Fixtures pending WP3lc_v1_register.csv, methodology_canonical.yaml, CHANGELOG.md, deprecated_params.yaml, lc_model_v1.xlsx must be created and committed in WP3.
  3. CI pipeline deferred to v1.2 — local + pre-commit only at v1.1.0 lock.
  4. Test coverage targets aspirational — coverage percentages in I.10.2 are estimates; actual coverage measured post-implementation.
  5. BP replication test stubtest_bp_irr_within_methodology uses placeholder band ±100 bps until BP IC paper locks; tighter band when BP IC locks.
  6. Additional unit tests deferred to v1.2 — yield model unit tests, OPEX model unit tests, debt sizing logic unit tests are v1.2 expansion. Annex I covers integration/acceptance, not unit-level.
  7. Performance benchmarks deferred to v1.2 — test suite runtime targets and parallelisation deferred.
  8. Methodology canonical YAML format final — YAML structure in I.8.2 is illustrative; final format locked when first WP2 outputs land.

I.12 References

  • NC-METH-001 v1.1.0 Part F § F.2 — acceptance test suite specification
  • NC-METH-001 v1.1.0 Part F § F.7.2 — Git repository structure
  • NC-METH-001 v1.1.0 Annex J — AUDIT-031 + AUDIT-034 closure tracking
  • NC-METH-001 v1.1.0 Annex H — methodology canonical CAPEX (gating Annex I closure)
  • NC-IC-LC-001 IC Paper v1.1 — LC canonical IRR 12.2% (replication test target)
  • NC-FM-LC-001 LC Financial Model v1.0 — model inputs source
  • NC-IS-LC-001 LC Investment Segment Register v1.0 — register source for replication tests
  • NC-MN-001-R3 v0.3 audit register — AUDIT-031 and AUDIT-034 origin
  • pytest documentation: https://docs.pytest.org/

End of Annex I v1.1.0 specification.

Implementation pending: WP3 closure (Weeks 5–8). All 18 tests must PASS for v1.1.0 lock per Part F § F.2.4.