canmet-btap 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- canmet_btap-0.2.1/PKG-INFO +72 -0
- canmet_btap-0.2.1/README.md +60 -0
- canmet_btap-0.2.1/btap/__init__.py +9 -0
- canmet_btap-0.2.1/btap/_compat.py +195 -0
- canmet_btap-0.2.1/btap/_sdk.py +56 -0
- canmet_btap-0.2.1/btap/audit/__init__.py +11 -0
- canmet_btap-0.2.1/btap/audit/coverage.py +73 -0
- canmet_btap-0.2.1/btap/audit/log.py +123 -0
- canmet_btap-0.2.1/btap/costing/__init__.py +14 -0
- canmet_btap-0.2.1/btap/costing/data/costs.csv +1969 -0
- canmet_btap-0.2.1/btap/costing/data/costs_local_factors.csv +2315 -0
- canmet_btap-0.2.1/btap/costing/data/envelope/README.md +30 -0
- canmet_btap-0.2.1/btap/costing/data/envelope/constructions.json +1337 -0
- canmet_btap-0.2.1/btap/costing/data/envelope/materials_glazing.csv +61 -0
- canmet_btap-0.2.1/btap/costing/data/envelope/materials_opaque.csv +214 -0
- canmet_btap-0.2.1/btap/costing/data/envelope/thermal_bridging.csv +71 -0
- canmet_btap-0.2.1/btap/costing/data/hvac/README.md +19 -0
- canmet_btap-0.2.1/btap/costing/data/hvac/hvac_vent_ahu.csv +925 -0
- canmet_btap-0.2.1/btap/costing/data/hvac/materials_hvac.csv +1686 -0
- canmet_btap-0.2.1/btap/costing/data/hvac/mech_sizing.json +502 -0
- canmet_btap-0.2.1/btap/costing/data/lighting/README.md +23 -0
- canmet_btap-0.2.1/btap/costing/data/lighting/lighting.csv +364 -0
- canmet_btap-0.2.1/btap/costing/data/lighting/lighting_sets.csv +2667 -0
- canmet_btap-0.2.1/btap/costing/data/lighting/materials_lighting.csv +267 -0
- canmet_btap-0.2.1/btap/costing/data/locations.csv +75 -0
- canmet_btap-0.2.1/btap/costing/envelope/__init__.py +12 -0
- canmet_btap-0.2.1/btap/costing/envelope/assemblies.py +103 -0
- canmet_btap-0.2.1/btap/costing/envelope/database.py +203 -0
- canmet_btap-0.2.1/btap/costing/envelope/envelope_costs.py +237 -0
- canmet_btap-0.2.1/btap/costing/envelope/interpolate.py +93 -0
- canmet_btap-0.2.1/btap/costing/envelope/quantify.py +177 -0
- canmet_btap-0.2.1/btap/costing/envelope/report.py +91 -0
- canmet_btap-0.2.1/btap/costing/envelope/thermal_bridging_costs.py +161 -0
- canmet_btap-0.2.1/btap/costing/hvac/__init__.py +0 -0
- canmet_btap-0.2.1/btap/costing/hvac/database.py +202 -0
- canmet_btap-0.2.1/btap/costing/hvac/geometry.py +366 -0
- canmet_btap-0.2.1/btap/costing/hvac/ledger.py +78 -0
- canmet_btap-0.2.1/btap/costing/hvac/quantify_equipment.py +546 -0
- canmet_btap-0.2.1/btap/costing/hvac/report.py +168 -0
- canmet_btap-0.2.1/btap/costing/hvac/ventilation.py +892 -0
- canmet_btap-0.2.1/btap/costing/lighting/__init__.py +0 -0
- canmet_btap-0.2.1/btap/costing/lighting/database.py +150 -0
- canmet_btap-0.2.1/btap/costing/lighting/fixtures.py +338 -0
- canmet_btap-0.2.1/btap/costing/lighting/report.py +54 -0
- canmet_btap-0.2.1/btap/costing/shw.py +214 -0
- canmet_btap-0.2.1/btap/modeling/__init__.py +430 -0
- canmet_btap-0.2.1/btap/modeling/envelope/__init__.py +0 -0
- canmet_btap-0.2.1/btap/modeling/envelope/constructions.py +254 -0
- canmet_btap-0.2.1/btap/modeling/envelope/geometry.py +91 -0
- canmet_btap-0.2.1/btap/modeling/geometry/__init__.py +0 -0
- canmet_btap-0.2.1/btap/modeling/geometry/bar.py +1466 -0
- canmet_btap-0.2.1/btap/modeling/geometry/footprint.py +705 -0
- canmet_btap-0.2.1/btap/modeling/geometry/helpers.py +61 -0
- canmet_btap-0.2.1/btap/modeling/geometry/plan.py +318 -0
- canmet_btap-0.2.1/btap/modeling/geometry/plan_query.py +241 -0
- canmet_btap-0.2.1/btap/modeling/geometry/plan_svg.py +313 -0
- canmet_btap-0.2.1/btap/modeling/geometry/render.py +250 -0
- canmet_btap-0.2.1/btap/modeling/geometry/render_worker.py +63 -0
- canmet_btap-0.2.1/btap/modeling/geometry/wizards.py +1784 -0
- canmet_btap-0.2.1/btap/modeling/hvac/__init__.py +0 -0
- canmet_btap-0.2.1/btap/modeling/hvac/builder.py +130 -0
- canmet_btap-0.2.1/btap/modeling/hvac/canonical.py +154 -0
- canmet_btap-0.2.1/btap/modeling/hvac/catalog.py +104 -0
- canmet_btap-0.2.1/btap/modeling/hvac/catalog_icons.py +138 -0
- canmet_btap-0.2.1/btap/modeling/hvac/catalog_report.py +1886 -0
- canmet_btap-0.2.1/btap/modeling/hvac/classify.py +854 -0
- canmet_btap-0.2.1/btap/modeling/hvac/components/__init__.py +0 -0
- canmet_btap-0.2.1/btap/modeling/hvac/components/coils.py +324 -0
- canmet_btap-0.2.1/btap/modeling/hvac/components/curves.py +152 -0
- canmet_btap-0.2.1/btap/modeling/hvac/components/ecm_air.py +270 -0
- canmet_btap-0.2.1/btap/modeling/hvac/components/schedules.py +73 -0
- canmet_btap-0.2.1/btap/modeling/hvac/data/5ZoneNoHVAC.osm +13395 -0
- canmet_btap-0.2.1/btap/modeling/hvac/data/curves.json +194 -0
- canmet_btap-0.2.1/btap/modeling/hvac/data/sizing.json +225 -0
- canmet_btap-0.2.1/btap/modeling/hvac/data/systems.json +1155 -0
- canmet_btap-0.2.1/btap/modeling/hvac/naming.py +110 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/__init__.py +0 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/ashp_baseboard.py +69 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/base_system.py +140 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/baseboards.py +33 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/baseboards_only.py +18 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/doas.py +49 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/doas_pthp.py +121 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/doas_vrf.py +53 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/evap_cooler.py +60 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/fan_coils.py +160 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/furnace.py +50 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/hp_plant_fancoils.py +241 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/mau_ptac.py +129 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/plant_loops.py +221 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/psz.py +219 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/unit_heaters.py +31 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/vav_reheat.py +206 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/vrf.py +26 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/wshp.py +115 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/zone_ervs.py +33 -0
- canmet_btap-0.2.1/btap/modeling/hvac/systems/zone_terminal.py +122 -0
- canmet_btap-0.2.1/btap/modeling/hvac/teardown.py +97 -0
- canmet_btap-0.2.1/btap/modeling/hvac/validation.py +40 -0
- canmet_btap-0.2.1/btap/necb/__init__.py +31 -0
- canmet_btap-0.2.1/btap/necb/cli.py +740 -0
- canmet_btap-0.2.1/btap/necb/compliance.py +1538 -0
- canmet_btap-0.2.1/btap/necb/data/decisions.json +801 -0
- canmet_btap-0.2.1/btap/necb/data/eui_targets_2025.json +37 -0
- canmet_btap-0.2.1/btap/necb/data/ghg_factors_2025.json +69 -0
- canmet_btap-0.2.1/btap/necb/data/necb_rules_2020.json +122 -0
- canmet_btap-0.2.1/btap/necb/data/necb_rules_2025.json +152 -0
- canmet_btap-0.2.1/btap/necb/decisions.py +78 -0
- canmet_btap-0.2.1/btap/necb/envelope/__init__.py +108 -0
- canmet_btap-0.2.1/btap/necb/envelope/climate.py +116 -0
- canmet_btap-0.2.1/btap/necb/envelope/data/README.md +41 -0
- canmet_btap-0.2.1/btap/necb/envelope/data/envelope_rules_2020.json +310 -0
- canmet_btap-0.2.1/btap/necb/envelope/data/envelope_rules_2025.json +311 -0
- canmet_btap-0.2.1/btap/necb/envelope/data/table_c1.json +11552 -0
- canmet_btap-0.2.1/btap/necb/envelope/fenestration.py +84 -0
- canmet_btap-0.2.1/btap/necb/envelope/prescriptive.py +409 -0
- canmet_btap-0.2.1/btap/necb/envelope/reference.py +468 -0
- canmet_btap-0.2.1/btap/necb/envelope/rules.py +107 -0
- canmet_btap-0.2.1/btap/necb/envelope/thermal_bridging.py +193 -0
- canmet_btap-0.2.1/btap/necb/eui_archetypes.py +690 -0
- canmet_btap-0.2.1/btap/necb/hvac/__init__.py +53 -0
- canmet_btap-0.2.1/btap/necb/hvac/checker.py +243 -0
- canmet_btap-0.2.1/btap/necb/hvac/data/README.md +47 -0
- canmet_btap-0.2.1/btap/necb/hvac/data/efficiencies_2020.json +2738 -0
- canmet_btap-0.2.1/btap/necb/hvac/data/efficiencies_2025.json +2839 -0
- canmet_btap-0.2.1/btap/necb/hvac/data/reference_rules_2020.json +1155 -0
- canmet_btap-0.2.1/btap/necb/hvac/data/reference_rules_2025.json +1157 -0
- canmet_btap-0.2.1/btap/necb/hvac/efficiency.py +1609 -0
- canmet_btap-0.2.1/btap/necb/hvac/energy_recovery.py +197 -0
- canmet_btap-0.2.1/btap/necb/hvac/reference.py +1689 -0
- canmet_btap-0.2.1/btap/necb/lighting/__init__.py +127 -0
- canmet_btap-0.2.1/btap/necb/lighting/_legacy_2011.py +288 -0
- canmet_btap-0.2.1/btap/necb/lighting/apply_lights.py +422 -0
- canmet_btap-0.2.1/btap/necb/lighting/data/README.md +81 -0
- canmet_btap-0.2.1/btap/necb/lighting/data/daylighting_controls_4_2_1_6.json +805 -0
- canmet_btap-0.2.1/btap/necb/lighting/data/exterior_lighting_2020.json +62 -0
- canmet_btap-0.2.1/btap/necb/lighting/data/led_lighting_2020.json +2781 -0
- canmet_btap-0.2.1/btap/necb/lighting/data/lighting_rules_2020.json +162 -0
- canmet_btap-0.2.1/btap/necb/lighting/data/lighting_rules_2025.json +166 -0
- canmet_btap-0.2.1/btap/necb/lighting/data/lpd_building_types_2025.json +136 -0
- canmet_btap-0.2.1/btap/necb/lighting/data/lpd_space_functions_2025.json +1291 -0
- canmet_btap-0.2.1/btap/necb/lighting/daylight_control_requirement.py +430 -0
- canmet_btap-0.2.1/btap/necb/lighting/daylighted_areas.py +448 -0
- canmet_btap-0.2.1/btap/necb/lighting/daylighting.py +513 -0
- canmet_btap-0.2.1/btap/necb/lighting/exterior.py +103 -0
- canmet_btap-0.2.1/btap/necb/lighting/reference.py +103 -0
- canmet_btap-0.2.1/btap/necb/lighting/reference_daylighting.py +152 -0
- canmet_btap-0.2.1/btap/necb/lighting/storage_garage/__init__.py +297 -0
- canmet_btap-0.2.1/btap/necb/lighting/storage_garage/perimeter.py +233 -0
- canmet_btap-0.2.1/btap/necb/lighting/storage_garage/schedules.py +219 -0
- canmet_btap-0.2.1/btap/necb/loads/__init__.py +72 -0
- canmet_btap-0.2.1/btap/necb/loads/apply.py +429 -0
- canmet_btap-0.2.1/btap/necb/loads/data/README.md +43 -0
- canmet_btap-0.2.1/btap/necb/loads/data/loads_rules_2020.json +99 -0
- canmet_btap-0.2.1/btap/necb/loads/data/loads_rules_2025.json +100 -0
- canmet_btap-0.2.1/btap/necb/loads/data/schedules_2020.json +8442 -0
- canmet_btap-0.2.1/btap/necb/loads/data/space_types_2020.json +25277 -0
- canmet_btap-0.2.1/btap/necb/loads/schedules.py +129 -0
- canmet_btap-0.2.1/btap/necb/loads/space_types.py +41 -0
- canmet_btap-0.2.1/btap/necb/report/__init__.py +121 -0
- canmet_btap-0.2.1/btap/necb/report/charts.py +84 -0
- canmet_btap-0.2.1/btap/necb/report/checklist.py +144 -0
- canmet_btap-0.2.1/btap/necb/report/html.py +184 -0
- canmet_btap-0.2.1/btap/necb/report/model_query.py +128 -0
- canmet_btap-0.2.1/btap/necb/report/sections.py +781 -0
- canmet_btap-0.2.1/btap/necb/report/svg.py +60 -0
- canmet_btap-0.2.1/btap/necb/shw/__init__.py +60 -0
- canmet_btap-0.2.1/btap/necb/shw/data/README.md +30 -0
- canmet_btap-0.2.1/btap/necb/shw/data/shw_rules_2020.json +257 -0
- canmet_btap-0.2.1/btap/necb/shw/data/shw_rules_2025.json +265 -0
- canmet_btap-0.2.1/btap/necb/shw/demand.py +317 -0
- canmet_btap-0.2.1/btap/necb/shw/efficiency.py +403 -0
- canmet_btap-0.2.1/btap/necb/shw/prescriptive.py +130 -0
- canmet_btap-0.2.1/btap/necb/shw/reference.py +37 -0
- canmet_btap-0.2.1/btap/necb/tiers.py +143 -0
- canmet_btap-0.2.1/btap/simulation/__init__.py +59 -0
- canmet_btap-0.2.1/btap/simulation/backends.py +364 -0
- canmet_btap-0.2.1/btap/simulation/engine.py +332 -0
- canmet_btap-0.2.1/btap/simulation/runner.py +284 -0
- canmet_btap-0.2.1/canmet_btap.egg-info/PKG-INFO +72 -0
- canmet_btap-0.2.1/canmet_btap.egg-info/SOURCES.txt +191 -0
- canmet_btap-0.2.1/canmet_btap.egg-info/dependency_links.txt +1 -0
- canmet_btap-0.2.1/canmet_btap.egg-info/entry_points.txt +2 -0
- canmet_btap-0.2.1/canmet_btap.egg-info/requires.txt +7 -0
- canmet_btap-0.2.1/canmet_btap.egg-info/top_level.txt +1 -0
- canmet_btap-0.2.1/pyproject.toml +112 -0
- canmet_btap-0.2.1/setup.cfg +4 -0
- canmet_btap-0.2.1/tests/test_compat.py +166 -0
- canmet_btap-0.2.1/tests/test_fixture_drift.py +178 -0
- canmet_btap-0.2.1/tests/test_inventory_validation.py +57 -0
- canmet_btap-0.2.1/tests/test_request_manifest.py +137 -0
- canmet_btap-0.2.1/tests/test_sdk_invariants.py +105 -0
- canmet_btap-0.2.1/tests/test_self_containment.py +368 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: canmet-btap
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: NECB 2020/2025 Part 8 performance-path compliance toolchain (Python port of the btap gem family)
|
|
5
|
+
License: LGPL-3.0-or-later
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: openstudio~=3.11.0
|
|
9
|
+
Requires-Dist: canmet-energyplus==25.2.0.2; sys_platform == "win32" or (sys_platform == "linux" and platform_machine == "x86_64")
|
|
10
|
+
Provides-Extra: tbd
|
|
11
|
+
Requires-Dist: canmet-tbd==3.5.2; extra == "tbd"
|
|
12
|
+
|
|
13
|
+
# btap (Python)
|
|
14
|
+
|
|
15
|
+
The Python port of the btap gem family — one distribution, five subpackages
|
|
16
|
+
mirroring the gems (`btap.audit`, `btap.simulation`, `btap.modeling`,
|
|
17
|
+
`btap.costing`, `btap.necb`), same one-way dependency direction (D-77).
|
|
18
|
+
The port is COMPLETE (M0–M8, 2026-08-28; the record is `../PORT_STATUS.md`
|
|
19
|
+
and D-79), verified three ways: every Ruby suite ported, the Leg-C oracle
|
|
20
|
+
goldens consumed (frozen AND live), and the Leg-B corpus diff holding the
|
|
21
|
+
Ruby and Python `btap-compliance` CLIs equivalent at the `none`, `sizing`
|
|
22
|
+
and `annual --quick` tiers.
|
|
23
|
+
|
|
24
|
+
Since R3 (D-81) **this implementation is primary and canonical**, and
|
|
25
|
+
since the R4 handoff (D-82) it is the only one that changes: Ruby
|
|
26
|
+
backports have stopped. Behaviour changes land here with a re-frozen
|
|
27
|
+
scenario baseline in the same PR (`verification/scenarios/freeze.py`);
|
|
28
|
+
the frozen suite — not a live Ruby comparison — is the regression net.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
cd python && python3 -m unittest discover tests # stdlib-only, no install needed (serial)
|
|
32
|
+
cd python && .venv/bin/pytest -n auto tests/ # parallel (pytest-xdist worker
|
|
33
|
+
# processes; ~10x on the E+ suites)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`btap/_compat.py` holds the cross-cutting Ruby-parity contracts (round-half-
|
|
37
|
+
away rounding, SDK Optional unwrapping, the NullAudit, determinism-critical
|
|
38
|
+
sorting, HTML escaping). New code uses those helpers, never the raw Python
|
|
39
|
+
equivalents — the differences they paper over are exactly the silent
|
|
40
|
+
Ruby-vs-Python drifts the census identified.
|
|
41
|
+
|
|
42
|
+
## The CLI (M6)
|
|
43
|
+
|
|
44
|
+
The umbrella pipeline and its command line are ported:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install . # installs the btap-compliance console script
|
|
48
|
+
btap-compliance model.osm --epw weather.epw # full 8.4.1.2 determination
|
|
49
|
+
python3 -m btap.necb.cli model.osm --simulate none # no-install equivalent
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Same seven exit codes as the Ruby CLI (0 compliant, 1 not compliant, 2
|
|
53
|
+
usage, 3 pre-flight, 4 simulation, 5 internal, 6 no determination), and the
|
|
54
|
+
same refusal to call a `--quick` week a determination. The Leg-B corpus gate
|
|
55
|
+
(`CLI_B=python bash verification/selftest.sh`) diffs this CLI's
|
|
56
|
+
`audit.json`/`report.json` against the Ruby CLI's over the whole corpus —
|
|
57
|
+
every pair equivalent at the `none`, `sizing`, and `annual` tiers.
|
|
58
|
+
|
|
59
|
+
## Thermal bridging (M7)
|
|
60
|
+
|
|
61
|
+
NECB 3.1.1.7 runs through **py-tbd** (native Python, no Ruby subprocess),
|
|
62
|
+
pinned by commit SHA to its `tbd-3.5.2-compat` branch — the revision
|
|
63
|
+
verified against the family's frozen Ruby TBD 3.5.2 / OSut 0.8.2 oracle
|
|
64
|
+
baseline:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install '.[tbd]'
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Without it the pipeline still works: `thermal_bridging=` degrades to the
|
|
71
|
+
same loud audited 3.1.1.7-not-accounted warning the Ruby gem emits without
|
|
72
|
+
its tbd gem — never a silent clear-field result.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# btap (Python)
|
|
2
|
+
|
|
3
|
+
The Python port of the btap gem family — one distribution, five subpackages
|
|
4
|
+
mirroring the gems (`btap.audit`, `btap.simulation`, `btap.modeling`,
|
|
5
|
+
`btap.costing`, `btap.necb`), same one-way dependency direction (D-77).
|
|
6
|
+
The port is COMPLETE (M0–M8, 2026-08-28; the record is `../PORT_STATUS.md`
|
|
7
|
+
and D-79), verified three ways: every Ruby suite ported, the Leg-C oracle
|
|
8
|
+
goldens consumed (frozen AND live), and the Leg-B corpus diff holding the
|
|
9
|
+
Ruby and Python `btap-compliance` CLIs equivalent at the `none`, `sizing`
|
|
10
|
+
and `annual --quick` tiers.
|
|
11
|
+
|
|
12
|
+
Since R3 (D-81) **this implementation is primary and canonical**, and
|
|
13
|
+
since the R4 handoff (D-82) it is the only one that changes: Ruby
|
|
14
|
+
backports have stopped. Behaviour changes land here with a re-frozen
|
|
15
|
+
scenario baseline in the same PR (`verification/scenarios/freeze.py`);
|
|
16
|
+
the frozen suite — not a live Ruby comparison — is the regression net.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
cd python && python3 -m unittest discover tests # stdlib-only, no install needed (serial)
|
|
20
|
+
cd python && .venv/bin/pytest -n auto tests/ # parallel (pytest-xdist worker
|
|
21
|
+
# processes; ~10x on the E+ suites)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`btap/_compat.py` holds the cross-cutting Ruby-parity contracts (round-half-
|
|
25
|
+
away rounding, SDK Optional unwrapping, the NullAudit, determinism-critical
|
|
26
|
+
sorting, HTML escaping). New code uses those helpers, never the raw Python
|
|
27
|
+
equivalents — the differences they paper over are exactly the silent
|
|
28
|
+
Ruby-vs-Python drifts the census identified.
|
|
29
|
+
|
|
30
|
+
## The CLI (M6)
|
|
31
|
+
|
|
32
|
+
The umbrella pipeline and its command line are ported:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install . # installs the btap-compliance console script
|
|
36
|
+
btap-compliance model.osm --epw weather.epw # full 8.4.1.2 determination
|
|
37
|
+
python3 -m btap.necb.cli model.osm --simulate none # no-install equivalent
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Same seven exit codes as the Ruby CLI (0 compliant, 1 not compliant, 2
|
|
41
|
+
usage, 3 pre-flight, 4 simulation, 5 internal, 6 no determination), and the
|
|
42
|
+
same refusal to call a `--quick` week a determination. The Leg-B corpus gate
|
|
43
|
+
(`CLI_B=python bash verification/selftest.sh`) diffs this CLI's
|
|
44
|
+
`audit.json`/`report.json` against the Ruby CLI's over the whole corpus —
|
|
45
|
+
every pair equivalent at the `none`, `sizing`, and `annual` tiers.
|
|
46
|
+
|
|
47
|
+
## Thermal bridging (M7)
|
|
48
|
+
|
|
49
|
+
NECB 3.1.1.7 runs through **py-tbd** (native Python, no Ruby subprocess),
|
|
50
|
+
pinned by commit SHA to its `tbd-3.5.2-compat` branch — the revision
|
|
51
|
+
verified against the family's frozen Ruby TBD 3.5.2 / OSut 0.8.2 oracle
|
|
52
|
+
baseline:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install '.[tbd]'
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Without it the pipeline still works: `thermal_bridging=` degrades to the
|
|
59
|
+
same loud audited 3.1.1.7-not-accounted warning the Ruby gem emits without
|
|
60
|
+
its tbd gem — never a silent clear-field result.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""btap — the NECB 2020/2025 Part 8 performance path, one distribution.
|
|
2
|
+
|
|
3
|
+
Five subpackages mirror the Ruby gem family and keep its one-way dependency
|
|
4
|
+
direction (D-77): necb -> costing -> modeling -> audit, simulation beside.
|
|
5
|
+
Import subpackages directly (``from btap.audit import AuditLog``); this root
|
|
6
|
+
deliberately imports none of them, so ``import btap`` never pulls the SDK.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.2.1"
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Cross-cutting Ruby-parity contracts for the btap port (D-79).
|
|
2
|
+
|
|
3
|
+
Every helper here papers over ONE specific Ruby-vs-Python semantic difference
|
|
4
|
+
the port-hazard census identified as a silent-drift hazard. Ported code MUST
|
|
5
|
+
use these instead of the raw Python equivalents:
|
|
6
|
+
|
|
7
|
+
- ``ruby_round`` — Ruby ``Float#round`` (half AWAY from zero, on the
|
|
8
|
+
shortest decimal representation); Python's ``round()`` is banker's.
|
|
9
|
+
- ``ruby_div`` — Ruby FLOAT division, which never raises: x/0.0 is
|
|
10
|
+
±Infinity and 0.0/0.0 is NaN, where Python raises ZeroDivisionError.
|
|
11
|
+
- ``opt``/``opt_or`` — SDK ``Optional`` unwrapping; routes every call through
|
|
12
|
+
one greppable site and retires the missing-``()`` truthy-bound-method bug.
|
|
13
|
+
- ``NullAudit`` — null-object stand-in for AuditLog, replacing Ruby's 235
|
|
14
|
+
``audit&.warn`` safe-navigation sites with plain ``audit.warn``.
|
|
15
|
+
- ``sorted_by_name`` — the determinism-critical ``sort_by(&:nameString)``.
|
|
16
|
+
- ``esc``/``Raw`` — the report's HTML escaping (exactly Ruby's four
|
|
17
|
+
substitutions; ``html.escape`` also escapes ``'`` and would diff).
|
|
18
|
+
- ``ruby_str`` — Ruby string interpolation of scalars (``true``/``false``/
|
|
19
|
+
``nil``→empty, Ruby float rendering) for byte-parity narrative text.
|
|
20
|
+
|
|
21
|
+
Stdlib only. This module must never import openstudio.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import math
|
|
27
|
+
from contextlib import contextmanager
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from decimal import ROUND_HALF_UP, Decimal
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ruby_round(x, ndigits: int = 0):
|
|
33
|
+
"""Ruby ``Float#round(ndigits)``: half away from zero on the value's
|
|
34
|
+
shortest decimal representation (so ``ruby_round(2.675, 2) == 2.68`` even
|
|
35
|
+
though the double is 2.67499...). Returns int for ``ndigits <= 0`` and
|
|
36
|
+
float otherwise, exactly as Ruby returns Integer/Float.
|
|
37
|
+
|
|
38
|
+
Non-finite input follows Ruby exactly: with ndigits > 0 the value comes
|
|
39
|
+
back unchanged (``Infinity.round(2) == Infinity``, ``NaN.round(4) ==
|
|
40
|
+
NaN``), while ndigits <= 0 must produce an Integer and so raises, as
|
|
41
|
+
Ruby's FloatDomainError does. This matters because Ruby float division
|
|
42
|
+
never raises — see ``ruby_div`` — so NaN/Infinity reach rounding on
|
|
43
|
+
perfectly ordinary code paths."""
|
|
44
|
+
if isinstance(x, int) and ndigits >= 0:
|
|
45
|
+
return x
|
|
46
|
+
value = float(x)
|
|
47
|
+
if math.isnan(value) or math.isinf(value):
|
|
48
|
+
if ndigits > 0:
|
|
49
|
+
return value
|
|
50
|
+
raise ValueError(f"cannot round {value} to an integer (Ruby: FloatDomainError)")
|
|
51
|
+
quantum = Decimal(1).scaleb(-ndigits)
|
|
52
|
+
d = Decimal(repr(value)).quantize(quantum, rounding=ROUND_HALF_UP)
|
|
53
|
+
return int(d) if ndigits <= 0 else float(d)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ruby_div(numerator, denominator) -> float:
|
|
57
|
+
"""Ruby FLOAT division, which never raises: ``x/0.0`` is ±Infinity and
|
|
58
|
+
``0.0/0.0`` is NaN, where Python raises ZeroDivisionError.
|
|
59
|
+
|
|
60
|
+
This is a family-wide porting hazard, not a curiosity. Ruby code
|
|
61
|
+
routinely divides by a possibly-zero area or count and then relies on the
|
|
62
|
+
result comparing false (``NaN <= 0.006`` is false), which is how several
|
|
63
|
+
NECB criteria skip an inapplicable half. Transliterating such a division
|
|
64
|
+
turns a silent skip into a crash, so every ported float division whose
|
|
65
|
+
denominator can be zero goes through here."""
|
|
66
|
+
n, d = float(numerator), float(denominator)
|
|
67
|
+
if d != 0.0:
|
|
68
|
+
return n / d
|
|
69
|
+
if n == 0.0 or math.isnan(n):
|
|
70
|
+
return math.nan
|
|
71
|
+
return math.inf if (n > 0) == (not math.copysign(1.0, d) < 0) else -math.inf
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def opt(optional):
|
|
75
|
+
"""Unwrap an SDK Optional: the value, or None when empty (Ruby's
|
|
76
|
+
``x.is_initialized ? x.get : nil``). None passes through.
|
|
77
|
+
|
|
78
|
+
ALWAYS route through this rather than calling ``.get()`` directly. The
|
|
79
|
+
Python bindings do not fail the way Ruby does: ``OptionalModel.get()`` on
|
|
80
|
+
an EMPTY optional RETURNS AN EMPTY MODEL instead of raising (so a failed
|
|
81
|
+
``Model.load`` flows onward silently), while ``OptionalDouble``/
|
|
82
|
+
``OptionalString`` raise ``SystemError`` and leave the C-level error
|
|
83
|
+
indicator set, which can segfault the interpreter on a later unrelated
|
|
84
|
+
call. Ruby raises cleanly in all of these. See D-79.
|
|
85
|
+
"""
|
|
86
|
+
if optional is None:
|
|
87
|
+
return None
|
|
88
|
+
return optional.get() if optional.is_initialized() else None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def opt_or(optional, default):
|
|
92
|
+
"""Unwrap an SDK Optional with a default (Ruby's ``.empty? ? d : .get``)."""
|
|
93
|
+
value = opt(optional)
|
|
94
|
+
return default if value is None else value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def sorted_by_name(objects):
|
|
98
|
+
"""``sort_by(&:nameString)`` — THE determinism idiom: every iteration whose
|
|
99
|
+
order reaches an output must pass through here (reference models, audit
|
|
100
|
+
entries, costing line items are reproducible because of it)."""
|
|
101
|
+
return sorted(objects, key=lambda o: o.nameString())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class NullAudit:
|
|
105
|
+
"""Null-object AuditLog: absorbs every write, so callers take
|
|
106
|
+
``audit=NullAudit()`` instead of Ruby's ``audit&.`` at 235 sites.
|
|
107
|
+
API-compatible with btap.audit.AuditLog; never import that here
|
|
108
|
+
(audit imports _compat, not the reverse)."""
|
|
109
|
+
|
|
110
|
+
building = None
|
|
111
|
+
entries: list = [] # always empty; writes are discarded, not stored
|
|
112
|
+
|
|
113
|
+
def decision(self, *args, **kwargs):
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def info(self, *args, **kwargs):
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def warn(self, *args, **kwargs):
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def warnings(self):
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
@contextmanager
|
|
127
|
+
def with_building(self, name):
|
|
128
|
+
yield
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class Raw:
|
|
133
|
+
"""Marks a pre-built HTML fragment as safe to embed unescaped
|
|
134
|
+
(Ruby's ``Html::Raw`` struct)."""
|
|
135
|
+
|
|
136
|
+
html: str
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def esc(value) -> str:
|
|
140
|
+
"""HTML-escape any value — exactly Ruby ``Html.esc``'s four gsubs, in
|
|
141
|
+
order. Deliberately NOT ``html.escape``: that escapes ``'`` too and the
|
|
142
|
+
report goldens would diff."""
|
|
143
|
+
return (
|
|
144
|
+
ruby_str(value)
|
|
145
|
+
.replace("&", "&")
|
|
146
|
+
.replace("<", "<")
|
|
147
|
+
.replace(">", ">")
|
|
148
|
+
.replace('"', """)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def ruby_str(value) -> str:
|
|
153
|
+
"""Ruby string interpolation (``"#{value}"``) for the scalar types the
|
|
154
|
+
audit narrative and report text actually carry: None→'' (nil), booleans
|
|
155
|
+
lowercase, Ruby float rendering (``1.0e+16``, not ``1e+16``), lists in
|
|
156
|
+
Ruby ``Array#to_s`` style. Best-effort beyond those — audit.txt is
|
|
157
|
+
narrative, only audit.json/report.json are Leg-B-gated."""
|
|
158
|
+
if value is None:
|
|
159
|
+
return ""
|
|
160
|
+
if value is True:
|
|
161
|
+
return "true"
|
|
162
|
+
if value is False:
|
|
163
|
+
return "false"
|
|
164
|
+
if isinstance(value, float):
|
|
165
|
+
return ruby_float_str(value)
|
|
166
|
+
if isinstance(value, list):
|
|
167
|
+
return "[" + ", ".join(_ruby_inspect(v) for v in value) + "]"
|
|
168
|
+
return str(value)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def ruby_float_str(x: float) -> str:
|
|
172
|
+
"""Ruby ``Float#to_s``: same shortest-repr digits and sci-notation
|
|
173
|
+
thresholds as Python's repr, but the mantissa always carries a decimal
|
|
174
|
+
point (Ruby ``1.0e+16`` vs Python ``1e+16``)."""
|
|
175
|
+
s = repr(x)
|
|
176
|
+
if "e" in s:
|
|
177
|
+
mantissa, exponent = s.split("e")
|
|
178
|
+
if "." not in mantissa:
|
|
179
|
+
mantissa += ".0"
|
|
180
|
+
return f"{mantissa}e{exponent}"
|
|
181
|
+
return s
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _ruby_inspect(value) -> str:
|
|
185
|
+
"""Ruby ``#inspect`` for array elements: strings quoted, nil literal."""
|
|
186
|
+
if value is None:
|
|
187
|
+
return "nil"
|
|
188
|
+
if isinstance(value, str):
|
|
189
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
190
|
+
return ruby_str(value)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# NOTE: SDK-touching helpers (ensure_sdk_hashable) live in btap._sdk — this
|
|
194
|
+
# module stays stdlib-only so btap.audit can depend on it and still run on a
|
|
195
|
+
# runner with no OpenStudio. The import-linter contract enforces that.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""SDK-touching cross-cutting helpers.
|
|
2
|
+
|
|
3
|
+
Separate from ``_compat`` on purpose: ``_compat`` is stdlib-only so that
|
|
4
|
+
``btap.audit`` — which the family contract keeps SDK-free, and which CI
|
|
5
|
+
exercises on a bare runner with no OpenStudio — can depend on it. Anything
|
|
6
|
+
that needs ``import openstudio`` lives here instead, and the import-linter
|
|
7
|
+
contract enforces the split.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
_sdk_hash_patched = False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def ensure_sdk_hashable() -> None:
|
|
16
|
+
"""Make SDK model objects usable as dict keys (idempotent).
|
|
17
|
+
|
|
18
|
+
The openstudio wheel's ModelObject/IdfObject define __eq__ (handle
|
|
19
|
+
equality) WITHOUT __hash__, so Python marks them unhashable — but the
|
|
20
|
+
ported code keys dicts by SpaceType/BuildingStory/ThermalZone exactly as
|
|
21
|
+
the Ruby did. This patches a handle-based __hash__ consistent with the
|
|
22
|
+
wheel's __eq__. Call it at the top of any module that keys a dict or set
|
|
23
|
+
by SDK objects.
|
|
24
|
+
"""
|
|
25
|
+
global _sdk_hash_patched
|
|
26
|
+
if _sdk_hash_patched:
|
|
27
|
+
return
|
|
28
|
+
import openstudio
|
|
29
|
+
|
|
30
|
+
def _handle_hash(self):
|
|
31
|
+
return hash(str(self.handle()))
|
|
32
|
+
|
|
33
|
+
openstudio.model.ModelObject.__hash__ = _handle_hash
|
|
34
|
+
openstudio.IdfObject.__hash__ = _handle_hash
|
|
35
|
+
_sdk_hash_patched = True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_model(path):
|
|
39
|
+
"""Load an .osm, refusing an unreadable path LOUDLY.
|
|
40
|
+
|
|
41
|
+
THE reason this exists: ``OptionalModel.get()`` on an EMPTY optional
|
|
42
|
+
RETURNS AN EMPTY MODEL rather than raising (Ruby raises), so an
|
|
43
|
+
unreadable path flows onward as a model with no spaces and no zones and
|
|
44
|
+
surfaces later as dozens of meaningless downstream failures. Other empty
|
|
45
|
+
Optionals raise ``SystemError`` and leave the C-level error indicator
|
|
46
|
+
set, which can segfault the interpreter on a later unrelated call.
|
|
47
|
+
|
|
48
|
+
Never write ``Model.load(...).get()``. Use this. The invariant is
|
|
49
|
+
enforced by tests/test_sdk_invariants.py, not just documented (D-79).
|
|
50
|
+
"""
|
|
51
|
+
import openstudio
|
|
52
|
+
|
|
53
|
+
loaded = openstudio.model.Model.load(openstudio.path(str(path)))
|
|
54
|
+
if not loaded.is_initialized():
|
|
55
|
+
raise ValueError(f"could not read an OpenStudio model at: {path}")
|
|
56
|
+
return loaded.get()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""btap.audit — the family's shared audit machinery (port of btap-audit).
|
|
2
|
+
|
|
3
|
+
One AuditLog class (entry schema, building stamping, article:/ruling:
|
|
4
|
+
citation axes, JSON + narrative rendering) and one article-coverage emitter.
|
|
5
|
+
No domain knowledge, no SDK — the bottom of the family.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from btap.audit.coverage import emit_coverage
|
|
9
|
+
from btap.audit.log import AuditLog
|
|
10
|
+
|
|
11
|
+
__all__ = ["AuditLog", "emit_coverage"]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Article-coverage emission (port of btap-audit's coverage.rb): the ONE
|
|
2
|
+
implementation of the completeness accounting every family module performs at
|
|
3
|
+
the end of its happy path.
|
|
4
|
+
|
|
5
|
+
Each domain owns an `article_coverage` manifest (implemented / partial /
|
|
6
|
+
not_implemented / satisfied_by_clone / host_scope) and resolves it its own
|
|
7
|
+
way; what every domain then does with it is identical and lives here: every
|
|
8
|
+
declared article lands in the audit with its status and how many decisions
|
|
9
|
+
cited it this run, so a missed requirement is visible in every log rather
|
|
10
|
+
than discovered by review.
|
|
11
|
+
|
|
12
|
+
partial/not_implemented WARN — except entries flagged `gap_owner: "modeller"`,
|
|
13
|
+
whose remaining gaps are wholly the modeller's responsibility: those emit as
|
|
14
|
+
info scope notes instead (project decision D-09).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from collections import Counter
|
|
21
|
+
|
|
22
|
+
_ARTICLE_RE = re.compile(r"\d+\.\d+(?:\.\d+)*\.")
|
|
23
|
+
# Strip ' (slice label)' / '(N)' suffixes, but KEEP the trailing dot: the scan
|
|
24
|
+
# above only ever yields keys ending in '.', so the dot is what stops
|
|
25
|
+
# '8.4.4.1.' from prefix-matching '8.4.4.14.' and claiming its citations.
|
|
26
|
+
# (report/checklist.rb#covered? guards the same collision the same way.)
|
|
27
|
+
_SLICE_SUFFIX_RE = re.compile(r"\s*\(.*\Z")
|
|
28
|
+
|
|
29
|
+
_INFO_STATUSES = ("implemented", "satisfied_by_clone", "host_scope")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def emit_coverage(coverage, audit):
|
|
33
|
+
"""`coverage` is the resolved article_coverage block — a dict with an
|
|
34
|
+
'articles' list of {article, title, status, how, gaps, gap_owner, code}
|
|
35
|
+
records. None is a deliberate no-op (a ruleset with no manifest emits
|
|
36
|
+
nothing). Entries append to `audit`; their `article:` tags are what the
|
|
37
|
+
citation count reads."""
|
|
38
|
+
if coverage is None:
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
cited = Counter()
|
|
42
|
+
for e in audit.entries:
|
|
43
|
+
cited.update(_ARTICLE_RE.findall(str(e.get("article") or "")))
|
|
44
|
+
for art in coverage["articles"]:
|
|
45
|
+
prefix = _SLICE_SUFFIX_RE.sub("", str(art["article"]))
|
|
46
|
+
applied = sum(n for a, n in cited.items() if a.startswith(prefix))
|
|
47
|
+
inputs = {"status": art["status"], "decisions_citing": applied}
|
|
48
|
+
if art.get("gap_owner") is not None:
|
|
49
|
+
inputs["gap_owner"] = art["gap_owner"]
|
|
50
|
+
# "Where is this dealt with" — path#method refs, carried into the
|
|
51
|
+
# audit so the AHJ trail answers the question without the repo.
|
|
52
|
+
if art.get("code") is not None:
|
|
53
|
+
inputs["code"] = art["code"]
|
|
54
|
+
status_text = art["status"].replace("_", " ")
|
|
55
|
+
how = art.get("how")
|
|
56
|
+
gaps = art.get("gaps")
|
|
57
|
+
if art["status"] in _INFO_STATUSES:
|
|
58
|
+
audit.info("coverage",
|
|
59
|
+
f"{art['title']} — {status_text}{': ' + how if how else ''}",
|
|
60
|
+
inputs=inputs, article=art["article"])
|
|
61
|
+
elif art.get("gap_owner") == "modeller": # scope note, not a warning (D-09)
|
|
62
|
+
action = f"{art['title']} — {status_text}, modeller scope"
|
|
63
|
+
if how:
|
|
64
|
+
action += f". Applied: {how}"
|
|
65
|
+
if gaps:
|
|
66
|
+
action += f". Modeller's responsibility: {gaps}"
|
|
67
|
+
audit.info("coverage", action, inputs=inputs, article=art["article"])
|
|
68
|
+
else: # partial / not_implemented
|
|
69
|
+
audit.warn("coverage",
|
|
70
|
+
f"{art['title']} — {status_text}"
|
|
71
|
+
f"{'. Applied: ' + how if how else ''}"
|
|
72
|
+
f"{'. Gaps: ' + gaps if gaps else ''}",
|
|
73
|
+
inputs=inputs, article=art["article"])
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""The family-wide decision/audit trail (port of btap-audit's log.rb).
|
|
2
|
+
|
|
3
|
+
Every consequential step records WHAT was decided, the INPUTS it was decided
|
|
4
|
+
from, the model EVIDENCE behind it, and (where applicable) the NECB ARTICLE or
|
|
5
|
+
data-table citation that mandates it — so QAQC can answer "why did zone X get
|
|
6
|
+
System 6?" from the log instead of diffing models.
|
|
7
|
+
|
|
8
|
+
Entry schema (all optional except step/action/level):
|
|
9
|
+
{step, target, action, inputs, value, article, ruling, evidence,
|
|
10
|
+
building, level} level: 'decision' | 'info' | 'warning'
|
|
11
|
+
|
|
12
|
+
ruling: WHICH adjudicated project decision(s) govern this code path — the D-XX
|
|
13
|
+
ids of the family's decision record. Where `article` cites the CODE that
|
|
14
|
+
mandates a value, `ruling` cites OUR judgement call about how that code was
|
|
15
|
+
read. Multiple ids are ONE space-separated string ('D-19 D-21'); consumers
|
|
16
|
+
scan r'\\bD-\\d{2}\\b'.
|
|
17
|
+
|
|
18
|
+
building: WHICH model the entry is about ('input model', 'proposed building',
|
|
19
|
+
'reference building'), stamped from the current building context a pipeline
|
|
20
|
+
sets at phase boundaries. None = cross-building comparison or verdict.
|
|
21
|
+
|
|
22
|
+
Port notes (D-79): entries are str-keyed dicts throughout — Ruby's
|
|
23
|
+
symbol-keys-in-memory / string-keys-in-JSON dualism collapses to str, which
|
|
24
|
+
leaves the serialized audit.json IDENTICAL (the Leg-B contract). None-valued
|
|
25
|
+
fields are dropped at insert (Ruby's `.compact`): consumers use
|
|
26
|
+
``e.get('article')`` truthiness, never key presence with a None fallback
|
|
27
|
+
difference. Contract: warnings are never silent.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
from contextlib import contextmanager
|
|
34
|
+
|
|
35
|
+
from btap._compat import ruby_str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AuditLog:
|
|
39
|
+
def __init__(self):
|
|
40
|
+
self.entries: list[dict] = []
|
|
41
|
+
self.building: str | None = None
|
|
42
|
+
|
|
43
|
+
@contextmanager
|
|
44
|
+
def with_building(self, name):
|
|
45
|
+
"""Stamp every entry recorded inside the block with the given building
|
|
46
|
+
context; restores the previous context afterwards (nestable)."""
|
|
47
|
+
previous = self.building
|
|
48
|
+
self.building = name
|
|
49
|
+
try:
|
|
50
|
+
yield self
|
|
51
|
+
finally:
|
|
52
|
+
self.building = previous
|
|
53
|
+
|
|
54
|
+
def decision(self, step, action, *, target=None, inputs=None, value=None,
|
|
55
|
+
article=None, ruling=None, evidence=None):
|
|
56
|
+
return self._add("decision", step, action, target, inputs, value,
|
|
57
|
+
article, ruling, evidence)
|
|
58
|
+
|
|
59
|
+
def info(self, step, action, *, target=None, inputs=None, value=None,
|
|
60
|
+
article=None, ruling=None, evidence=None):
|
|
61
|
+
return self._add("info", step, action, target, inputs, value,
|
|
62
|
+
article, ruling, evidence)
|
|
63
|
+
|
|
64
|
+
def warn(self, step, action, *, target=None, inputs=None, value=None,
|
|
65
|
+
article=None, ruling=None, evidence=None):
|
|
66
|
+
return self._add("warning", step, action, target, inputs, value,
|
|
67
|
+
article, ruling, evidence)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def warnings(self):
|
|
71
|
+
"""Warning entries only. The recorded level is 'warning', not 'warn'."""
|
|
72
|
+
return [e for e in self.entries if e["level"] == "warning"]
|
|
73
|
+
|
|
74
|
+
def to_json(self) -> str:
|
|
75
|
+
return json.dumps(self.entries, indent=2, ensure_ascii=False)
|
|
76
|
+
|
|
77
|
+
def __str__(self):
|
|
78
|
+
"""Human-readable narrative, one line per entry — same fixed-width
|
|
79
|
+
shape as Ruby's to_s (the umbrella's checklist classifier parses the
|
|
80
|
+
action text case-SENSITIVELY: violations SHOUTED, passes lowercase)."""
|
|
81
|
+
lines = []
|
|
82
|
+
for e in self.entries:
|
|
83
|
+
line = "[%-8s] %-13s %s" % (e["level"], e["step"], e["action"])
|
|
84
|
+
# Segment gates are RUBY truthiness (only nil/false falsy — '' and
|
|
85
|
+
# 0 print), not Python truthiness.
|
|
86
|
+
if _truthy(e.get("building")):
|
|
87
|
+
line += f" | building: {e['building']}"
|
|
88
|
+
if _truthy(e.get("target")):
|
|
89
|
+
line += f" | target: {e['target']}"
|
|
90
|
+
if _truthy(e.get("inputs")):
|
|
91
|
+
line += f" | inputs: {_compact_hash(e['inputs'])}"
|
|
92
|
+
if _truthy(e.get("value")):
|
|
93
|
+
line += f" | value: {ruby_str(e['value'])}"
|
|
94
|
+
if _truthy(e.get("evidence")):
|
|
95
|
+
line += f" | evidence: {e['evidence']}"
|
|
96
|
+
if _truthy(e.get("article")):
|
|
97
|
+
line += f" | per {e['article']}"
|
|
98
|
+
if _truthy(e.get("ruling")):
|
|
99
|
+
line += f" | ruling {e['ruling']}"
|
|
100
|
+
lines.append(line)
|
|
101
|
+
return "\n".join(lines)
|
|
102
|
+
|
|
103
|
+
def _add(self, level, step, action, target, inputs, value, article,
|
|
104
|
+
ruling, evidence):
|
|
105
|
+
entry = {"step": step, "target": target, "action": action,
|
|
106
|
+
"inputs": inputs, "value": value, "article": article,
|
|
107
|
+
"ruling": ruling, "evidence": evidence,
|
|
108
|
+
"building": self.building, "level": level}
|
|
109
|
+
self.entries.append({k: v for k, v in entry.items() if v is not None})
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _truthy(value) -> bool:
|
|
114
|
+
"""Ruby truthiness: everything except nil and false."""
|
|
115
|
+
return value is not None and value is not False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _compact_hash(inputs: dict) -> str:
|
|
119
|
+
"""Ruby's inputs rendering: 'k=v, k=v', arrays joined with '/'."""
|
|
120
|
+
return ", ".join(
|
|
121
|
+
f"{k}={'/'.join(ruby_str(x) for x in v) if isinstance(v, list) else ruby_str(v)}"
|
|
122
|
+
for k, v in inputs.items()
|
|
123
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""btap.costing — pricing of model objects (port of the btap-costing gem).
|
|
2
|
+
|
|
3
|
+
HVAC equipment BOMs, envelope assemblies, lighting fixtures, and SHW — one
|
|
4
|
+
package owning the whole licensed-data seam. The vendored CSVs under
|
|
5
|
+
``data/`` are PLACEHOLDER schema copies; real RS-Means values are injected
|
|
6
|
+
at runtime (costs_csv=/local_factors_csv= kwargs, or BTAP_COSTING_DIR /
|
|
7
|
+
OPENSTUDIO_COSTING_DIR) and are never committed or redistributed.
|
|
8
|
+
|
|
9
|
+
Dependency direction is the design (D-77): costing imports btap.modeling
|
|
10
|
+
and btap.audit, NEVER btap.necb. Where a costing rule needs NECB-owned
|
|
11
|
+
geometry (the daylighted-area sensors), the NECB layer passes a provider in.
|
|
12
|
+
Sub-namespaced (hvac/envelope/lighting + shw) because Database/Report exist
|
|
13
|
+
per domain; import the domain modules directly.
|
|
14
|
+
"""
|