Model/domain/sap10_ml/tests/test_rdsap_uvalues.py
Khalim Conn-Kowlessar 485a74028e Slice 96: flat-roof U-value defaults — RdSAP 10 §5.11 Table 18 col (3)
Cert 0330 (mid-terrace boiler, Summary_000897.pdf) Summary path was at
Δ +0.4667 SAP vs worksheet 61.5993 because Ext1's flat roof fell through
`_ROOF_BY_AGE` (Table 18 column (1), pitched-roof "between joists"
defaults) to 0.40 W/m²K for age D — the spec value is 2.30 W/m²K from
column (3) "Flat roof" (RdSAP 10 spec page 45).

RdSAP 10 §5.11 Table 18 column (3) verbatim:
  Age A,B,C,D → 2.30; E → 1.50; F → 0.68; G → 0.40; H,I → 0.35;
  J,K → 0.25; L → 0.18; M → 0.15.

Footnote (a): "If the roof insulation is 'none' use U = 2.3 (all roof
types, except for thatched roofs)" — confirms the col-3 entries for
old ages are the uninsulated row, applied because cert 0330's Ext1
lodges "Flat" construction with no measured insulation thickness.

Changes:
- `_FLAT_ROOF_BY_AGE` added in rdsap_uvalues.py
- `u_roof` gains `is_flat_roof: bool = False` parameter
- `heat_transmission_from_cert` detects flat roofs from
  `part.roof_construction_type` ("flat" substring) and routes through
  the new column.

Effect on baseline:
- cert 0330 Summary chain test: RED Δ+0.4667 → GREEN at 1e-4 (worksheet
  total fabric heat loss 237.7549 W/K matches cascade to 4 d.p.)
- cert 001479 Layer 4 chain test: unchanged (Main pitched, no flat
  components)
- cohort certs 000477/000516: unchanged (no flat roofs)
- golden cert 0300-2747-7640-2526-2135: SAP residual +1 → 0 (improved),
  Ext1 is genuinely flat; pe/co2 residuals re-pinned. The dwelling has
  the same Main-pitched + Ext1-flat shape as cert 0330; same fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:45 +00:00

1385 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for RdSAP10 U-value cascade-defaulting helpers.
Reference values are taken from the RdSAP10 specification (12 February 2024):
- Tables 6-9 — wall U-values per country
- Table 14 — insulation thickness <-> resistance
- Table 15 — party-wall U-values
- Table 18 — roof U-values by age band
- Table 19 — floor insulation defaults by age band
- Table 20 — exposed/semi-exposed upper-floor U-values
- Table 21 — thermal-bridging factor y
- Table 24 — window U-values
- Table 26 — door U-values
The functions never raise on missing inputs; they cascade through age-band
defaults -> country defaults -> final mid-range value so that callers can
treat the envelope as if RdSAP had assigned an as-built default.
"""
from typing import Optional
import pytest
from domain.sap10_ml.rdsap_uvalues import (
Country,
WALL_CAVITY,
WALL_INSULATION_FILLED_CAVITY,
WALL_SOLID_BRICK,
WALL_STONE_GRANITE,
WALL_SYSTEM_BUILT,
WALL_TIMBER_FRAME,
thermal_bridging_y,
u_door,
u_exposed_floor,
u_floor,
u_party_wall,
u_roof,
u_rr_default_all_elements,
u_rr_flat_ceiling,
u_rr_slope,
u_rr_stud_wall,
u_wall,
u_window,
)
# ----- Walls -----
def test_u_wall_description_with_measured_transmittance_returns_parsed_value() -> None:
# Arrange — full SAP (not RdSAP) assessments lodge a measured/calculated
# U-value per BS EN ISO 6946 in the wall description string, e.g.
# "Average thermal transmittance 0.18 W/m²K". These certs typically
# have wall_construction, wall_insulation_type, and age_band all None
# because the cascade defaults don't apply — the assessor's measured
# value takes precedence (RdSAP 10 §5.3). Affects ~15% of corpus.
# Act
result = u_wall(
country=None,
age_band=None,
construction=None,
insulation_thickness_mm=None,
description="Average thermal transmittance 0.18 W/m²K",
)
# Assert
assert result == pytest.approx(0.18, abs=0.001)
def test_u_wall_description_with_malformed_transmittance_falls_through_to_cascade() -> None:
# Arrange — a description containing the phrase but a malformed value
# (e.g. just a stray dot) should NOT short-circuit to a parse failure;
# it should fall through to the construction cascade and return a
# spec-defined value. This is the calculator's "trust the cert when
# parseable, never raise" contract.
# Act
result = u_wall(
country=Country.ENG,
age_band="G",
construction=WALL_CAVITY,
insulation_thickness_mm=0,
description="Average thermal transmittance . W/m²K",
)
# Assert — Table 6 cavity-as-built row at band G = 0.60 W/m²K.
assert result == pytest.approx(0.60, abs=0.001)
def test_u_wall_solid_brick_with_ni_thickness_uses_50mm_row_per_table6_footnote() -> None:
# Arrange — 685 corpus certs lodge solid-brick walls with
# wall_insulation_type ∈ {1 external, 3 internal} and
# wall_insulation_thickness="NI" (Not Indicated). RdSAP 10 Table 6
# footnote: "If a wall is known to have additional insulation but
# the insulation thickness is unknown, use the row in the table for
# 50 mm insulation." Our `_parse_thickness_mm("NI")` returns 0, which
# combined with `insulation_present=True` must now route to the 50 mm
# bucket (U=0.55 at A-E), not the as-built bucket (U=1.7).
# Act
result = u_wall(
country=Country.ENG,
age_band="B",
construction=WALL_SOLID_BRICK,
insulation_thickness_mm=0,
insulation_present=True,
)
# Assert — Stone/solid brick with 50 mm row at band B = 0.55.
assert result == pytest.approx(0.55, abs=0.001)
def test_u_wall_cavity_as_built_insulated_assumed_routes_to_filled_cavity_row() -> None:
# Arrange — 1 171 corpus certs (~4% of scanned bulk) lodge
# wall_insulation_type=4 ("as-built / assumed") together with the
# description "Cavity wall, as built, insulated (assumed)". The
# assessor is saying: this cavity is filled, but I haven't measured
# the thickness. Spec footnote on Table 6 covers this: "If a wall
# is known to have additional insulation but the insulation thickness
# is unknown, use the row in the table for 50 mm insulation" — but
# legacy convention (used by the production recommendation engine)
# is to route this to the Filled-cavity row, U = 0.7 at A-E. We
# follow the legacy convention here for parity with the cert assessor.
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=WALL_CAVITY,
insulation_thickness_mm=None,
insulation_present=False, # type=4 maps to wall_ins_present=False
wall_insulation_type=4,
description="Cavity wall, as built, insulated (assumed)",
)
# Assert
assert result == pytest.approx(0.7, abs=0.001)
def test_u_wall_cavity_as_built_no_insulation_stays_at_table6_cavity_as_built_row() -> None:
# Arrange — the same wall_insulation_type=4 ("as-built / assumed")
# cert population also contains 686 "Cavity wall, as built, no
# insulation (assumed)" entries which must continue to route to the
# Cavity-as-built row of Table 6 (U=1.5 at band E). The "no
# insulation" substring marker takes precedence over the
# "insulated"-substring filled-cavity rule, so this case is
# disambiguated from "Cavity wall, as built, insulated (assumed)".
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=WALL_CAVITY,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=4,
description="Cavity wall, as built, no insulation (assumed)",
)
# Assert
assert result == pytest.approx(1.5, abs=0.001)
def test_u_wall_cavity_as_built_partial_insulation_routes_to_filled_cavity_row() -> None:
# Arrange — 147 corpus certs lodge "Cavity wall, as built, partial
# insulation (assumed)" with wall_insulation_type=4. The legacy
# production map (recommendations/rdsap_tables.py:753) routes these
# to "Filled cavity" — same destination as the "insulated (assumed)"
# case. We match that interpretation for parity with the cert
# assessor and the production recommendation engine.
# Act
result = u_wall(
country=Country.ENG,
age_band="D", # 1950-1966 — typical partial-fill retrofit cohort
construction=WALL_CAVITY,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=4,
description="Cavity wall, as built, partial insulation (assumed)",
)
# Assert — Filled-cavity row at band D = 0.7 W/m²K.
assert result == pytest.approx(0.7, abs=0.001)
def test_u_wall_description_without_transmittance_phrase_routes_through_cascade() -> None:
# Arrange — the measured-U dispatcher must only fire when the
# description contains the "thermal transmittance" phrase. The
# ordinary surveyor-text descriptions (e.g. "Cavity wall, filled
# cavity") must still route through the construction cascade.
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=WALL_CAVITY,
insulation_thickness_mm=0,
insulation_present=True,
wall_insulation_type=WALL_INSULATION_FILLED_CAVITY,
description="Cavity wall, filled cavity",
)
# Assert — should return the Filled-cavity row value, not anything
# parsed out of the description.
assert result == pytest.approx(0.7, abs=0.001)
def test_u_wall_filled_cavity_england_age_band_e_returns_table6_value() -> None:
# Arrange — RdSAP 10 Table 6 (England) row "Filled cavity", age band E
# (1967-1975) -> 0.7 W/m^2K. The cert records this as the triple
# (wall_construction=4 cavity, wall_insulation_type=2 filled,
# wall_insulation_thickness="NI"). Spec: domain/sap10_calculator/docs/specs/rdsap-10-
# specification-2025-06-10.pdf page 33.
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=WALL_CAVITY,
insulation_thickness_mm=0,
insulation_present=True,
wall_insulation_type=WALL_INSULATION_FILLED_CAVITY,
)
# Assert
assert result == pytest.approx(0.7, abs=0.001)
@pytest.mark.parametrize(
"age_band,expected_u",
[
# RdSAP 10 Table 6 (England) "Filled cavity" row sampled at three bands:
# A (pre-1900) = 0.7 — early cavity dwellings, retro-filled.
# F (1976-1982) = 0.40 — first cavity-insulation era.
# K (2007+) = 0.30 — "assumed as built" (†) — matches Cavity-as-built K.
("A", 0.7),
("F", 0.40),
("K", 0.30),
],
)
def test_u_wall_filled_cavity_england_row_matches_table6_across_age_bands(
age_band: str, expected_u: float
) -> None:
# Arrange — the dispatcher must return the right cell of the
# "Filled cavity" row, not just the band-E value used by the tracer.
# Act
result = u_wall(
country=Country.ENG,
age_band=age_band,
construction=WALL_CAVITY,
insulation_thickness_mm=0,
insulation_present=True,
wall_insulation_type=WALL_INSULATION_FILLED_CAVITY,
)
# Assert
assert result == pytest.approx(expected_u, abs=0.001)
def test_u_wall_unfilled_cavity_england_age_band_e_unchanged_at_1_5() -> None:
# Arrange — adding the filled-cavity dispatcher must not regress the
# existing as-built path. Band E + cavity construction + no insulation
# type set -> the "Cavity as built" row of Table 6, U = 1.5 W/m^2K.
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=WALL_CAVITY,
insulation_thickness_mm=0,
insulation_present=False,
wall_insulation_type=None,
)
# Assert
assert result == pytest.approx(1.5, abs=0.001)
def test_u_wall_cavity_as_built_england_age_band_g_returns_table6_value() -> None:
# Arrange — Table 6, England, Cavity as built, age band G -> 0.60 W/m^2K.
# Act
result = u_wall(
country=Country.ENG,
age_band="G",
construction=WALL_CAVITY,
insulation_thickness_mm=0,
)
# Assert
assert result == pytest.approx(0.60, abs=0.001)
def test_u_wall_solid_brick_with_100mm_insulation_age_band_e_returns_table6_value() -> None:
# Arrange — Table 6, England, Solid brick with 100mm insulation, age band E -> 0.32 W/m^2K.
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=WALL_SOLID_BRICK,
insulation_thickness_mm=100,
)
# Assert
assert result == pytest.approx(0.32, abs=0.001)
def test_u_wall_scotland_age_band_m_returns_country_specific_table7_value() -> None:
# Arrange — Scotland's Table 7 has tighter age-M U-values (0.17 vs England's 0.26).
# Act
result = u_wall(
country=Country.SCT,
age_band="M",
construction=WALL_CAVITY,
insulation_thickness_mm=0,
)
# Assert
assert result == pytest.approx(0.17, abs=0.001)
def test_u_wall_timber_frame_as_built_age_band_a_returns_table6_value() -> None:
# Arrange — Timber frame as built, age A, England -> 2.5 W/m^2K.
# Act
result = u_wall(
country=Country.ENG,
age_band="A",
construction=WALL_TIMBER_FRAME,
insulation_thickness_mm=0,
)
# Assert
assert result == pytest.approx(2.5, abs=0.001)
def test_u_wall_falls_back_to_age_band_default_when_construction_unknown() -> None:
# Arrange — construction missing; falls back to cavity-typical for age band G.
# Act
result = u_wall(
country=Country.ENG,
age_band="G",
construction=None,
insulation_thickness_mm=0,
)
# Assert — cavity-as-built for G is 0.60 (matches RdSAP "assume as-built" rule).
assert result == pytest.approx(0.60, abs=0.001)
def test_u_wall_falls_back_to_mid_range_default_when_everything_unknown() -> None:
# Arrange — no signal at all.
# Act
result = u_wall(
country=None,
age_band=None,
construction=None,
insulation_thickness_mm=None,
)
# Assert — mid-range fallback ~1.5 (Cavity-as-built mid-band E typical).
assert result == pytest.approx(1.5, abs=0.001)
def test_u_wall_description_sandstone_overrides_cavity_default_for_age_e() -> None:
# Arrange — construction integer is missing on the cert. _DEFAULT_WALL_BY_AGE
# would pick cavity for age E (1.0 W/m^2K uninsulated), but the surveyor's
# walls[i].description clearly identifies sandstone -> 1.7 W/m^2K.
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=None,
insulation_thickness_mm=None,
description="Sandstone or limestone, as built, no insulation (assumed)",
)
# Assert
assert result == pytest.approx(1.7, abs=0.001)
def test_u_wall_description_granite_or_whinstone_picks_stone_default() -> None:
# Arrange — Scotland whinstone (granite-family) walls, age D, construction
# null. Should resolve to STONE_GRANITE uninsulated -> 1.7 (age D index 3).
# Act
result = u_wall(
country=Country.ENG,
age_band="D",
construction=None,
insulation_thickness_mm=None,
description="Granite or whinstone, as built",
)
# Assert
assert result == pytest.approx(1.7, abs=0.001)
def test_u_wall_description_solid_brick_picks_solid_brick_default() -> None:
# Arrange — construction null, description names solid brick. For age E
# uninsulated solid brick -> 1.7 W/m^2K (vs cavity 1.0).
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=None,
insulation_thickness_mm=None,
description="Solid brick, as built, no insulation (assumed)",
)
# Assert
assert result == pytest.approx(1.7, abs=0.001)
def test_u_wall_explicit_construction_beats_description() -> None:
# Arrange — wall_construction integer is cavity (4); ignore any conflicting
# description text. Cavity-as-built age E -> 1.5 W/m^2K, NOT stone's 1.7.
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=WALL_CAVITY,
insulation_thickness_mm=None,
description="Granite, as built", # ignored
)
# Assert
assert result == pytest.approx(1.5, abs=0.001)
def test_u_wall_description_unmatched_falls_back_to_age_band_default() -> None:
# Arrange — construction null, description says nothing recognisable.
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=None,
insulation_thickness_mm=None,
description="something unparseable",
)
# Assert — cavity default for age E uninsulated -> 1.5 W/m^2K.
assert result == pytest.approx(1.5, abs=0.001)
def test_u_wall_uses_rdsap_unknown_thickness_default_of_50mm_when_insulated_but_unknown() -> None:
# Arrange — RdSAP10 footnote: if wall is known insulated but thickness unknown, use 50mm row.
# System built with 50mm insulation, England, age band G -> 0.35 W/m^2K.
# Act
result = u_wall(
country=Country.ENG,
age_band="G",
construction=WALL_SYSTEM_BUILT,
insulation_thickness_mm=None, # unknown but insulation present
insulation_present=True,
)
# Assert
assert result == pytest.approx(0.35, abs=0.001)
# ----- Roofs -----
def test_u_roof_description_with_measured_transmittance_returns_parsed_value() -> None:
# Arrange — ~1 140 corpus certs lodge a full-SAP measured roof
# U-value in the description, e.g. "Average thermal transmittance
# 0.11 W/m²K". The age-band cascade is bypassed: the assessor's
# measured/calculated value is used directly. Same contract as
# `u_wall` (S-B24) and `u_floor` (S-B29 cycle 1).
# Act
result = u_roof(
country=Country.ENG,
age_band="C",
insulation_thickness_mm=None,
description="Average thermal transmittance 0.11 W/m²K",
)
# Assert
assert result == pytest.approx(0.11, abs=0.001)
def test_u_roof_ni_thickness_with_insulated_description_applies_50mm_per_section_5_11_4() -> None:
# Arrange — 346 corpus certs lodge roof_insulation_thickness="NI"
# (Not Indicated, parsed to 0 by _parse_thickness_mm). When the
# description also signals retrofit insulation ("Pitched, insulated
# (assumed)" / "Flat, insulated" / "Roof room(s), insulated
# (assumed)"), RdSAP 10 §5.11.4 (page 44) footnote applies:
# "If retrofit insulation present of unknown thickness use 50 mm".
# That maps to Table 16 row "50 mm at joists at ceiling level" = 0.68
# W/m²K — vs the current 2.30 we return when thickness=0 hits the
# Table 16 row-0 lookup.
# Act
result = u_roof(
country=Country.ENG,
age_band="C",
insulation_thickness_mm=0, # parsed from "NI"
description="Pitched, insulated (assumed)",
)
# Assert
assert result == pytest.approx(0.68, abs=0.01)
def test_u_roof_ni_thickness_with_no_insulation_description_stays_at_2_30() -> None:
# Arrange — 706 corpus certs lodge "Pitched, no insulation
# (assumed)" which can co-occur with thickness="NI". The
# description-based override for retrofit-insulated roofs must
# respect the "no insulation" negation: `_described_as_insulated`
# returns False on "no insulation" substring, so the Table 16
# row-0 lookup applies and U = 2.30 W/m²K stays.
# Act
result = u_roof(
country=Country.ENG,
age_band="C",
insulation_thickness_mm=0,
description="Pitched, no insulation (assumed)",
)
# Assert
assert result == pytest.approx(2.30, abs=0.01)
def test_u_roof_age_band_j_pitched_returns_table18_value() -> None:
# Arrange — Table 18, pitched insulation between joists, age J -> 0.16 W/m^2K.
# Act
result = u_roof(country=Country.ENG, age_band="J", insulation_thickness_mm=None)
# Assert
assert result == pytest.approx(0.16, abs=0.001)
def test_u_roof_with_explicit_insulation_thickness_uses_table16() -> None:
# Arrange — Table 16 joist insulation 200mm -> 0.21 W/m^2K.
# Act
result = u_roof(country=Country.ENG, age_band="G", insulation_thickness_mm=200)
# Assert
assert result == pytest.approx(0.21, abs=0.001)
def test_u_roof_unknown_age_band_falls_back_to_mid_range() -> None:
# Arrange — nothing known.
# Act
result = u_roof(country=None, age_band=None, insulation_thickness_mm=None)
# Assert — mid-range default ~0.4 (Table 18 age G typical).
assert result == pytest.approx(0.4, abs=0.001)
def test_u_roof_flat_age_band_d_returns_table18_col3_value() -> None:
# Arrange — RdSAP 10 §5.11 Table 18 page 45 column (3) "Flat roof":
# age band D, thickness unknown → U = 2.30 W/m²K. Column (1)
# (pitched-between-joists default) returns 0.40 for the same age
# band; routing must pick column (3) when the per-bp roof
# construction lodges as flat.
# Act
result = u_roof(
country=Country.ENG, age_band="D", insulation_thickness_mm=None,
is_flat_roof=True,
)
# Assert
assert abs(result - 2.30) <= 1e-4
def test_u_roof_flat_age_band_g_returns_table18_col3_value() -> None:
# Arrange — Table 18 column (3) flat-roof default is 0.40 for age G,
# the cross-over point where the flat-roof and pitched-roof columns
# agree. Confirms the dict is populated across the full age range.
# Act
result = u_roof(
country=Country.ENG, age_band="G", insulation_thickness_mm=None,
is_flat_roof=True,
)
# Assert
assert abs(result - 0.40) <= 1e-4
def test_u_roof_flat_age_band_l_returns_table18_col3_value() -> None:
# Arrange — Table 18 column (3) flat-roof default is 0.18 for age L,
# the modern band where both columns agree.
# Act
result = u_roof(
country=Country.ENG, age_band="L", insulation_thickness_mm=None,
is_flat_roof=True,
)
# Assert
assert abs(result - 0.18) <= 1e-4
def test_u_roof_description_no_insulation_overrides_age_band_default() -> None:
# Arrange — surveyor description on a Victorian roof says uninsulated;
# Table 18 age-B default (0.40) is far too optimistic. Table 16 row 0mm
# joist insulation is 2.30 W/m^2K.
# Act
result = u_roof(
country=Country.ENG,
age_band="B",
insulation_thickness_mm=None,
description="Pitched, no insulation (assumed)",
)
# Assert
assert result == pytest.approx(2.30, abs=0.001)
def test_u_roof_description_limited_insulation_overrides_age_band_default() -> None:
# Arrange — "limited insulation" maps to Table 16 row 12mm -> 1.50 W/m^2K.
# Act
result = u_roof(
country=Country.ENG,
age_band="D",
insulation_thickness_mm=None,
description="Roof room(s), limited insulation",
)
# Assert
assert result == pytest.approx(1.50, abs=0.001)
def test_u_roof_description_uninsulated_synonym_also_triggers_high_u() -> None:
# Arrange — surveyor writes "uninsulated" (no space) instead of "no insulation".
# Act
result = u_roof(
country=Country.ENG,
age_band="C",
insulation_thickness_mm=None,
description="Flat, uninsulated",
)
# Assert
assert result == pytest.approx(2.30, abs=0.001)
def test_u_roof_description_well_insulated_does_not_override_default() -> None:
# Arrange — description says "insulated"; do NOT override the Table 18
# age-G default of 0.40 with a penalty.
# Act
result = u_roof(
country=Country.ENG,
age_band="G",
insulation_thickness_mm=None,
description="Pitched, insulated at rafters",
)
# Assert
assert result == pytest.approx(0.40, abs=0.001)
def test_u_roof_explicit_thickness_beats_description() -> None:
# Arrange — when surveyor measured 200mm joist insulation, Table 16 wins
# regardless of any description text. 200mm -> 0.21 W/m^2K.
# Act
result = u_roof(
country=Country.ENG,
age_band="B",
insulation_thickness_mm=200,
description="No insulation", # ignored because thickness is explicit
)
# Assert
assert result == pytest.approx(0.21, abs=0.001)
# ----- Floors -----
def test_u_floor_description_with_measured_transmittance_returns_parsed_value() -> None:
# Arrange — ~1 391 corpus certs lodge a full-SAP measured floor
# U-value in the description, e.g. "Average thermal transmittance
# 0.18 W/m²K". The BS EN ISO 13370 calculation is bypassed: the
# assessor's measured/calculated value is used directly. Same
# contract as `u_wall` (S-B24).
# Act
result = u_floor(
country=Country.ENG,
age_band="B",
construction=None,
insulation_thickness_mm=None,
area_m2=100.0,
perimeter_m=40.0,
wall_thickness_mm=300,
description="Average thermal transmittance 0.18 W/m²K",
)
# Assert
assert result == pytest.approx(0.18, abs=0.001)
def test_u_floor_ni_thickness_with_insulated_description_applies_50mm_per_table19_footnote() -> None:
# Arrange — 2 413 corpus certs (~12%) lodge floors with
# floor_insulation_thickness="NI" (Not Indicated, which our
# _parse_thickness_mm returns as 0) AND a description "Solid,
# insulated (assumed)" or "Suspended, insulated (assumed)". The
# assessor sees insulation but hasn't measured the thickness.
# RdSAP 10 §5.12 Table 19 footnote (2):
# "For floors which have retrofitted insulation, use the greater
# of 50 mm and the thickness according to the age band."
# Band B's age-band default is 0 mm, so max(50, 0) = 50 mm applies.
# Geometry: 100 m² × 40 m perimeter, w=0.3, gives B=5, d_t=2.758
# (with R_f from 50 mm/0.035 = 1.429); U = 2 × 1.5 × ln(π×5/2.758 + 1)
# / (π×5 + 2.758) ≈ 0.31 W/m²K.
# Act
result = u_floor(
country=Country.ENG,
age_band="B",
construction=None,
insulation_thickness_mm=0, # parsed from "NI"
area_m2=100.0,
perimeter_m=40.0,
wall_thickness_mm=300,
description="Solid, insulated (assumed)",
)
# Assert
assert result == pytest.approx(0.31, abs=0.02)
def test_u_floor_ni_thickness_with_no_insulation_description_stays_uninsulated() -> None:
# Arrange — 8 221 corpus certs lodge "Solid, no insulation
# (assumed)" with thickness="NI". The Table 19 footnote (2) override
# must not fire on these: the "no insulation" substring takes
# precedence over the "insulated" substring per
# `_described_as_insulated`. Same geometry as the cycle-1 test;
# uninsulated U should be ~0.60 W/m²K (B=5, d_t=0.615 with R_f=0).
# Act
result = u_floor(
country=Country.ENG,
age_band="B",
construction=None,
insulation_thickness_mm=0, # parsed from "NI"
area_m2=100.0,
perimeter_m=40.0,
wall_thickness_mm=300,
description="Solid, no insulation (assumed)",
)
# Assert
assert result == pytest.approx(0.60, abs=0.02)
def test_u_floor_solid_uninsulated_typical_geometry_returns_iso_13370_value() -> None:
# Arrange — solid floor, age C, England.
# BS EN ISO 13370 with A=80, P=36, w=0.22m, soil g=1.5, Rsi=0.17, Rse=0.04, Rf=0
# d_t = 0.22 + 1.5 * (0.17 + 0 + 0.04) = 0.535
# B = 2 * 80 / 36 = 4.444
# d_t < B so U = 2 * 1.5 * ln(pi*B/d_t + 1) / (pi*B + d_t)
# = 3 * ln(pi*4.444/0.535 + 1) / (pi*4.444 + 0.535)
# = 3 * ln(27.10) / (14.49)
# = 3 * 3.300 / 14.49 = 0.683 -> rounds to 0.68
# Act
result = u_floor(
country=Country.ENG,
age_band="C",
construction=None,
insulation_thickness_mm=None,
area_m2=80.0,
perimeter_m=36.0,
wall_thickness_mm=220,
)
# Assert
assert result == pytest.approx(0.68, abs=0.05)
def test_u_floor_age_b_unknown_construction_uses_suspended_timber_per_table_19_footnote_1() -> None:
# Arrange — RdSAP10 §5.12 Table 19 footnote (1) routes age A, B with
# unknown floor_construction to the suspended-timber branch. Geometry
# is taken from Elmhurst worksheet U985-0001-000490 Main Dwelling
# (A=14.85, P=7.42, w=0.400) — the worksheet records U=0.71 W/m²K,
# confirming the suspended-floor formula on §5.12 (page 46) is the
# one Elmhurst applies for this fixture.
# Act
result = u_floor(
country=Country.ENG,
age_band="B",
construction=None,
insulation_thickness_mm=None,
area_m2=14.85,
perimeter_m=7.42,
wall_thickness_mm=400,
)
# Assert
assert result == pytest.approx(0.71, abs=0.01)
def test_u_floor_with_insulation_lowers_u_value() -> None:
# Arrange — same geometry but with 100mm insulation -> R_f = 0.1/0.035 = 2.857.
# Act
insulated = u_floor(
country=Country.ENG,
age_band="K",
construction=None,
insulation_thickness_mm=100,
area_m2=80.0,
perimeter_m=36.0,
wall_thickness_mm=220,
)
# Assert — well below uninsulated case (~0.27 W/m^2K).
assert insulated < 0.3
def test_u_exposed_floor_age_b_unknown_insulation_uses_table_20_row_a_to_g() -> None:
# Arrange — RdSAP10 §5.13 Table 20 (page 47) gives U-values for
# exposed and semi-exposed upper floors keyed on age band +
# insulation thickness. The "Insulation unknown or as built"
# column at age band A-G = 1.20 W/m²K. Elmhurst worksheet
# U985-0001-000490 Extension 1 records U=1.20 for its exposed
# timber floor (1900-1929, no insulation lodged) — this lookup
# reproduces that exact value without any geometry input.
# Act
result = u_exposed_floor(age_band="B", insulation_thickness_mm=None)
# Assert
assert result == pytest.approx(1.20, abs=0.001)
def test_u_floor_falls_back_to_mid_range_when_geometry_unknown() -> None:
# Arrange — geometry missing.
# Act
result = u_floor(
country=None,
age_band=None,
construction=None,
insulation_thickness_mm=None,
area_m2=None,
perimeter_m=None,
wall_thickness_mm=None,
)
# Assert — mid-range fallback ~0.7 W/m^2K (solid-uninsulated mid-band typical).
assert result == pytest.approx(0.7, abs=0.05)
# ----- Windows -----
def test_u_window_single_glazed_pvc_returns_table24_value() -> None:
# Arrange — Table 24: single glazing, any period, PVC/wooden frame -> 4.8 W/m^2K.
# Act
result = u_window(installed_year=None, glazing_type="single", frame_type="pvc")
# Assert
assert result == pytest.approx(4.8, abs=0.001)
def test_u_window_post_2022_pvc_returns_low_table24_value() -> None:
# Arrange — Table 24: double or triple glazed, 2022 or later, PVC -> 1.4 W/m^2K.
# Act
result = u_window(installed_year=2023, glazing_type="double", frame_type="pvc")
# Assert
assert result == pytest.approx(1.4, abs=0.001)
def test_u_window_falls_back_to_mid_range_when_unknown() -> None:
# Arrange — nothing known.
# Act
result = u_window(installed_year=None, glazing_type=None, frame_type=None)
# Assert — mid-range default ~2.5 (pre-2002 double glazed PVC typical).
assert result == pytest.approx(2.5, abs=0.5)
# ----- Doors -----
def test_u_door_age_band_a_uninsulated_returns_table26_value() -> None:
# Arrange — Table 26: age A-J unisulated -> 3.0 W/m^2K.
# Act
result = u_door(country=Country.ENG, age_band="A", insulated=False, insulated_u_value=None)
# Assert
assert result == pytest.approx(3.0, abs=0.001)
def test_u_door_age_band_m_uninsulated_returns_lower_table26_value() -> None:
# Arrange — Table 26: age M -> 1.4 W/m^2K.
# Act
result = u_door(country=Country.ENG, age_band="M", insulated=False, insulated_u_value=None)
# Assert
assert result == pytest.approx(1.4, abs=0.001)
def test_u_door_insulated_uses_explicit_u_value_when_supplied() -> None:
# Arrange — door declared insulated with U-value 1.0 from cert.
# Act
result = u_door(country=Country.ENG, age_band="C", insulated=True, insulated_u_value=1.0)
# Assert
assert result == pytest.approx(1.0, abs=0.001)
# ----- Party walls -----
def test_u_party_wall_solid_masonry_returns_zero() -> None:
# Arrange — Table 15: solid masonry / timber frame / system built -> 0.0 W/m^2K.
# Act
result = u_party_wall(party_wall_construction=WALL_SOLID_BRICK)
# Assert
assert result == pytest.approx(0.0, abs=0.001)
def test_u_party_wall_unfilled_cavity_returns_table15_value() -> None:
# Arrange — Table 15: cavity masonry unfilled -> 0.5 W/m^2K.
# Act
result = u_party_wall(party_wall_construction=WALL_CAVITY)
# Assert
assert result == pytest.approx(0.5, abs=0.001)
def test_u_party_wall_unknown_returns_table15_house_default() -> None:
# Arrange — Table 15: unable to determine, house -> 0.25 W/m^2K.
# Act
result = u_party_wall(party_wall_construction=None)
# Assert
assert result == pytest.approx(0.25, abs=0.001)
# ----- Thermal bridging -----
def test_thermal_bridging_y_age_band_g_returns_table21_value() -> None:
# Arrange — Table 21: ages A-I -> 0.15.
# Act
result = thermal_bridging_y(age_band="G")
# Assert
assert result == pytest.approx(0.15, abs=0.001)
def test_thermal_bridging_y_age_band_j_returns_table21_value() -> None:
# Arrange — Table 21: age J -> 0.11.
# Act
result = thermal_bridging_y(age_band="J")
# Assert
assert result == pytest.approx(0.11, abs=0.001)
def test_thermal_bridging_y_age_band_l_returns_table21_value() -> None:
# Arrange — Table 21: ages K, L, M -> 0.08.
# Act
result = thermal_bridging_y(age_band="L")
# Assert
assert result == pytest.approx(0.08, abs=0.001)
def test_thermal_bridging_y_unknown_age_band_returns_mid_range() -> None:
# Arrange — age unknown.
# Act
result = thermal_bridging_y(age_band=None)
# Assert — mid-range fallback ~0.15 (the most common value across age bands).
assert result == pytest.approx(0.15, abs=0.001)
def test_country_unknown_string_falls_back_to_england() -> None:
# Arrange — Country.from_code('XX') -> Country.ENG.
# Act
result = Country.from_code("XX")
# Assert
assert result is Country.ENG
def test_country_from_code_recognises_known_codes() -> None:
# Arrange / Act / Assert
assert Country.from_code("ENG") is Country.ENG
assert Country.from_code("WAL") is Country.WAL
assert Country.from_code("SCT") is Country.SCT
assert Country.from_code("NIR") is Country.NIR
assert Country.from_code("EAW") is Country.ENG # England-and-Wales aggregate maps to ENG
def test_u_rr_default_all_elements_age_band_b_returns_table18_col4_value() -> None:
"""RdSAP10 §5.11.4 + Table 18 column (4) — "Room-in-roof, all elements"
as-built / unknown default. Age band B (1900-1929) → 2.30 W/m²K (the
uninsulated row carries footnote (1): "value from the table applies
for unknown and as built")."""
# Arrange / Act
result = u_rr_default_all_elements(country=Country.ENG, age_band="B")
# Assert
assert result == pytest.approx(2.30, abs=0.001)
def test_u_rr_default_all_elements_table18_col4_matches_spec_across_age_bands() -> None:
"""Table 18 column (4) per RdSAP10 spec page 45:
A-D 2.30, E 1.50, F 0.80, G 0.50, H 0.35, I 0.35, J 0.30,
K 0.25, L 0.18, M 0.15.
"""
# Arrange — expected RR-all-elements U-values for England.
expected = {
"A": 2.30, "B": 2.30, "C": 2.30, "D": 2.30,
"E": 1.50, "F": 0.80, "G": 0.50, "H": 0.35,
"I": 0.35, "J": 0.30, "K": 0.25, "L": 0.18, "M": 0.15,
}
# Act / Assert
for age_band, want in expected.items():
got = u_rr_default_all_elements(country=Country.ENG, age_band=age_band)
assert got == pytest.approx(want, abs=0.001), (
f"age={age_band}: got {got}, want {want}"
)
def test_u_rr_default_all_elements_scotland_age_band_k_returns_0_20_per_footnote() -> None:
"""Table 18 footnote (2): "0.20 W/m²K in Scotland" applies to the
age band K row of column (4). Other age bands unchanged."""
# Arrange / Act
result = u_rr_default_all_elements(country=Country.SCT, age_band="K")
# Assert
assert result == pytest.approx(0.20, abs=0.001)
def test_u_rr_default_all_elements_unknown_age_band_falls_back_to_mid_range() -> None:
"""Robustness: no age band → return the mid-range default rather than
raising. Picks the column (4) value at age G (0.50) as a sensible
middle estimate, matching the cascade convention used by `u_roof`."""
# Arrange / Act
result = u_rr_default_all_elements(country=None, age_band=None)
# Assert
assert result == pytest.approx(0.50, abs=0.001)
# ----- Room-in-roof Table 17 lookups (insulation thickness known) -----
def test_u_rr_slope_table17_col1a_mineral_wool_100mm_returns_0_40() -> None:
"""RdSAP10 §5.11.3 + Table 17 column (1a): "Insulated slope - sloping
ceiling, mineral wool or EPS slab" 100 mm row → 0.40 W/m²K."""
# Arrange / Act
result = u_rr_slope(
country=Country.ENG,
age_band="B",
insulation_thickness_mm=100,
insulation_type="mineral_wool",
)
# Assert
assert result == pytest.approx(0.40, abs=0.001)
def test_u_rr_slope_table17_col1b_pur_pir_100mm_returns_0_30() -> None:
"""Table 17 column (1b): "Insulated slope - sloping ceiling, PUR or
PIR optional" 100 mm row → 0.30 W/m²K. The PUR/PIR rigid foam route
gives a tighter U than mineral wool at the same thickness."""
# Arrange / Act
result = u_rr_slope(
country=Country.ENG,
age_band="B",
insulation_thickness_mm=100,
insulation_type="pir",
)
# Assert
assert result == pytest.approx(0.30, abs=0.001)
def test_u_rr_flat_ceiling_table17_col2a_mineral_wool_100mm_returns_0_54() -> None:
"""Table 17 column (2a): "Insulated slope - flat ceiling, mineral wool
or EPS slab" 100 mm row → 0.54 W/m²K."""
# Arrange / Act
result = u_rr_flat_ceiling(
country=Country.ENG,
age_band="B",
insulation_thickness_mm=100,
insulation_type="mineral_wool",
)
# Assert
assert result == pytest.approx(0.54, abs=0.001)
def test_u_rr_stud_wall_table17_col3a_mineral_wool_100mm_returns_0_36() -> None:
"""Table 17 column (3a): "Stud wall u-value For Room in Roof, mineral
wool or EPS slab" 100 mm row → 0.36 W/m²K. (Used by the U985 worksheet
for 000477's RR stud walls.)"""
# Arrange / Act
result = u_rr_stud_wall(
country=Country.ENG,
age_band="B",
insulation_thickness_mm=100,
insulation_type="mineral_wool",
)
# Assert
assert result == pytest.approx(0.36, abs=0.001)
def test_u_rr_slope_table17_none_row_uninsulated_returns_2_30() -> None:
"""Table 17 "none" row (every column collapses to 2.3 when no
insulation). Used by the U985 worksheet for 000477's RR slope panels
that lodge as uninsulated."""
# Arrange / Act
result = u_rr_slope(
country=Country.ENG,
age_band="B",
insulation_thickness_mm=0,
insulation_type="mineral_wool",
)
# Assert
assert result == pytest.approx(2.30, abs=0.001)
def test_u_rr_flat_ceiling_table17_col2b_pir_over_400mm_returns_0_09() -> None:
"""Table 17 row ">400 mm" column (2b) PUR/PIR → 0.09 W/m²K. The U985
worksheet for 000477 lodges 0.14 for "External roof Main" which is
Table 17 col (2a) row >400 (mineral wool) — but this test uses the
PIR column for completeness."""
# Arrange / Act
result = u_rr_flat_ceiling(
country=Country.ENG,
age_band="B",
insulation_thickness_mm=450,
insulation_type="pir",
)
# Assert
assert result == pytest.approx(0.09, abs=0.001)
def test_u_rr_slope_unknown_thickness_falls_back_to_table18_all_elements() -> None:
"""When `insulation_thickness_mm is None`, Table 17 doesn't apply and
we cascade to Table 18 col (4) "Room-in-roof, all elements" by age
band — same fallback as the spec text at §5.11.3 / §5.11.4.
For age band B, that's the 2.30 W/m²K uninsulated default."""
# Arrange / Act
result = u_rr_slope(
country=Country.ENG,
age_band="B",
insulation_thickness_mm=None,
insulation_type="mineral_wool",
)
# Assert
assert result == pytest.approx(2.30, abs=0.001)
def test_u_rr_stud_wall_thickness_125mm_takes_nearest_tabulated_row_below() -> None:
"""Table 17 row alignment: an arbitrary thickness picks the nearest
tabulated row ≤ supplied (the same convention `u_roof` uses against
Table 16). 125 mm matches the exact row → col (3a) = 0.31 W/m²K."""
# Arrange / Act
result = u_rr_stud_wall(
country=Country.ENG,
age_band="B",
insulation_thickness_mm=125,
insulation_type="mineral_wool",
)
# Assert
assert result == pytest.approx(0.31, abs=0.001)
# ----- Description-cascade cohort pins (Walls) -----
#
# The Elmhurst worksheet fixtures lodge `walls=[]` and so cannot exercise
# the description-driven branches of `u_wall`. The 8 golden API certs DO
# carry `walls[0].description` strings that route through `_described_as_
# insulated`, `_wall_type_from_description`, and the Table 6 footnote
# 50 mm-bucket override. These tests pin every (description, age) pair
# seen in that cohort against the RdSAP10 Table 6 (England) value the
# spec mandates — closing the cascade-coverage gap identified during the
# 2026-05-24 audit (description cascade was 100% spec-correct on clean
# Table 6 rows; this test locks that in for regression).
#
# Cases routing through §5.7 (solid brick from wall thickness) or §5.8
# (stone/brick with insulation, ages AD — formula not table) are
# intentionally excluded — they need separate pinning when those
# formulas land.
_TABLE_6_ENG_WALL_COHORT_PINS: tuple[tuple[str, str, Optional[int], Optional[int], Optional[int], bool, float], ...] = (
# (description, age_band, wall_construction, wall_insulation_type,
# insulation_thickness_mm, insulation_present, expected_u_w_per_m2k)
# `insulation_present` mirrors the heat_transmission cascade: type != 4 (NONE)
# OR description asserts insulation per _described_as_insulated.
("Sandstone, as built, insulated (assumed)", "J", 2, 4, 0, True, 0.25), # cert 0240 (50 mm bucket per footnote)
("Cavity wall, filled cavity", "C", 4, 2, 0, True, 0.7), # cert 8135 bp0
("Cavity wall, filled cavity", "D", 4, 2, 0, True, 0.7), # cert 0300, 7536 bp0
("Cavity wall, filled cavity", "F", 4, 2, 0, True, 0.40), # cert 7536 bp2
("Cavity wall, filled cavity", "G", 4, 2, 0, True, 0.35), # cert 8135 bp1
("Cavity wall, filled cavity", "L", 4, 4, 0, False, 0.28), # cert 7536 bp1 (assumed-as-built †)
("Cavity wall, as built, no insulation (assumed)", "D", 4, 4, 0, False, 1.5), # cert 0390-2954, 9390
)
@pytest.mark.parametrize(
"description, age_band, construction, insulation_type, thickness_mm, insulation_present, expected_u",
_TABLE_6_ENG_WALL_COHORT_PINS,
)
def test_u_wall_matches_table6_for_every_cohort_description_age_pair(
description: str,
age_band: str,
construction: Optional[int],
insulation_type: Optional[int],
thickness_mm: Optional[int],
insulation_present: bool,
expected_u: float,
) -> None:
# Arrange — inputs replicate what `heat_transmission_from_cert` feeds
# `u_wall` for the corresponding building part in the golden cert cohort.
# Act
u = u_wall(
country=Country.ENG,
age_band=age_band,
construction=construction,
insulation_thickness_mm=thickness_mm,
insulation_present=insulation_present,
description=description,
wall_insulation_type=insulation_type,
)
# Assert
assert abs(u - expected_u) < 1e-4
# ----- Description-cascade cohort pins (Roofs) -----
#
# Mirror of the wall cohort pins above. The Elmhurst worksheet fixtures
# lodge `roofs=[]`, so the cascade-pin tests do not exercise u_roof's
# description path either. The 8 golden API certs lodge `roofs[].
# description` strings — these tests pin each (description, age,
# thickness) tuple seen in that cohort against the RdSAP10 Table 16
# (loft-insulation-thickness-known) value the spec mandates.
#
# Excluded: ambiguous joined-description cases where one bp lodges no
# thickness and another lodges a value — the calc routes through Table
# 18 defaults whose interaction with the description cascade needs
# separate pinning. "(another dwelling above)" is also excluded — its
# u_roof value is ignored by heat_transmission once roof_area is zeroed.
_TABLE_16_ENG_ROOF_COHORT_PINS: tuple[tuple[str, str, int, float], ...] = (
# (joined_description, age_band, thickness_mm, expected_u_w_per_m2k)
# Table 16 col 1 — thickness-known path, U independent of age band.
("Pitched, 100 mm loft insulation", "D", 100, 0.40), # cert 7536 bp0
("Pitched, 100 mm loft insulation | Pitched, insulated (assumed)", "D", 100, 0.40), # cert 7536 bp0 (joined)
("Pitched, 270 mm loft insulation", "D", 270, 0.16), # cert 0300 bp0
("Pitched, 300 mm loft insulation | Flat, no insulation", "D", 300, 0.14), # cert 0390-2954 bp0
("Pitched, 300 mm loft insulation | Roof room(s), limited insulation (assumed)", "A", 300, 0.14), # cert 6035 bp0
("Pitched, 300 mm loft insulation | Flat, insulated", "C", 300, 0.14), # cert 8135 bp0
("Pitched, 300 mm loft insulation | Pitched, 100 mm loft insulation", "B", 300, 0.14), # cert 2130 bp0
("Pitched, 400+ mm loft insulation | Pitched, insulated (assumed)", "J", 400, 0.11), # cert 0240 bp0
)
@pytest.mark.parametrize(
"description, age_band, thickness_mm, expected_u",
_TABLE_16_ENG_ROOF_COHORT_PINS,
)
def test_u_roof_matches_table16_for_every_cohort_description_thickness_pair(
description: str,
age_band: str,
thickness_mm: int,
expected_u: float,
) -> None:
# Arrange — inputs replicate what `heat_transmission_from_cert` feeds
# `u_roof` for the main building part in the golden cert cohort.
# Act
u = u_roof(
country=Country.ENG,
age_band=age_band,
insulation_thickness_mm=thickness_mm,
description=description,
)
# Assert
assert abs(u - expected_u) < 1e-4
# ----- §5.12 formula cascade cohort pins (Floors) -----
#
# u_floor is formula-driven (BS EN ISO 13370 + RdSAP10 §5.12) rather
# than table-lookup, so each pin asserts a per-geometry value derived
# by hand from the spec formula. Two cases from cert 0240 (main +
# extension) cover the dt < B and dt > B branches of the solid-floor
# branch; suspended-floor + Table 19 footnote (2) overrides land in
# follow-on slices when cohort coverage demands them.
#
# Hand-derivation for the first row (cert 0240 bp0):
# age J → Table 19 default insulation = 75 mm
# w = 0.3 m (default), g = 1.5, Rsi+Rse = 0.21, Rf = 0.001×75/0.035 = 2.143
# dt = 0.3 + 1.5×(0.21 + 2.143) = 3.829
# B = 2×97.72/36.45 = 5.362 → dt < B branch
# U = 2g·ln(πB/dt + 1)/(πB + dt) = 0.2447 → rounds to 0.24
_FLOOR_FORMULA_COHORT_PINS: tuple[tuple[str, str, Optional[int], float, float, Optional[int], float], ...] = (
# (description, age, construction, area_m2, perimeter_m, wall_thickness_mm, expected_u)
("Solid, insulated (assumed)", "J", 1, 97.72, 36.45, None, 0.24), # cert 0240 bp0 (dt < B)
("Solid, insulated (assumed)", "J", 1, 20.61, 13.45, None, 0.29), # cert 0240 bp1 (dt > B)
)
@pytest.mark.parametrize(
"description, age_band, construction, area_m2, perimeter_m, wall_thickness_mm, expected_u",
_FLOOR_FORMULA_COHORT_PINS,
)
def test_u_floor_matches_section_5_12_formula_for_cohort_geometry(
description: str,
age_band: str,
construction: Optional[int],
area_m2: float,
perimeter_m: float,
wall_thickness_mm: Optional[int],
expected_u: float,
) -> None:
# Arrange — inputs replicate what `heat_transmission_from_cert` feeds
# `u_floor` for the corresponding building part in the cohort.
# Act
u = u_floor(
country=Country.ENG,
age_band=age_band,
construction=construction,
insulation_thickness_mm=None,
area_m2=area_m2,
perimeter_m=perimeter_m,
wall_thickness_mm=wall_thickness_mm,
description=description,
)
# Assert
assert abs(u - expected_u) < 1e-4