typehaus 0.1.0a0__py3-none-any.whl
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.
- typehaus/__init__.py +15 -0
- typehaus/_meta.py +28 -0
- typehaus/analysis.py +149 -0
- typehaus/checks/__init__.py +30 -0
- typehaus/checks/advisory/__init__.py +3 -0
- typehaus/checks/advisory/checks.py +297 -0
- typehaus/checks/building_science/__init__.py +5 -0
- typehaus/checks/building_science/condensation.py +221 -0
- typehaus/checks/building_science/glaser.py +403 -0
- typehaus/checks/building_science/wwr.py +112 -0
- typehaus/checks/code/__init__.py +0 -0
- typehaus/checks/code/mn_energy.py +186 -0
- typehaus/checks/code/mn_residential/__init__.py +6 -0
- typehaus/checks/code/mn_residential/profile.py +34 -0
- typehaus/checks/code/mn_residential/rules.py +372 -0
- typehaus/checks/code/site.py +65 -0
- typehaus/checks/integrity/__init__.py +3 -0
- typehaus/checks/integrity/checks.py +120 -0
- typehaus/checks/mep/__init__.py +7 -0
- typehaus/checks/mep/electrical.py +266 -0
- typehaus/checks/mep/hvac.py +76 -0
- typehaus/checks/mep/plumbing.py +272 -0
- typehaus/checks/mep/vent_path.py +107 -0
- typehaus/checks/permit.py +96 -0
- typehaus/checks/pytest_plugin.py +56 -0
- typehaus/checks/registry.py +163 -0
- typehaus/checks/run.py +97 -0
- typehaus/checks/structural/__init__.py +6 -0
- typehaus/checks/structural/checks.py +272 -0
- typehaus/checks/structural/deck.py +451 -0
- typehaus/checks/structural/deck_tables.py +139 -0
- typehaus/checks/structural/interference.py +401 -0
- typehaus/checks/structural/stairs.py +173 -0
- typehaus/cli/__init__.py +7 -0
- typehaus/cli/app.py +904 -0
- typehaus/cli/digest.py +33 -0
- typehaus/cli/furniture_import.py +88 -0
- typehaus/cli/prices.py +190 -0
- typehaus/cli/scaffold.py +276 -0
- typehaus/cli/variants.py +209 -0
- typehaus/diff/__init__.py +39 -0
- typehaus/diff/assembly_compare.py +116 -0
- typehaus/diff/compare.py +301 -0
- typehaus/diff/equivalence.py +283 -0
- typehaus/diff/ifc_adapter.py +299 -0
- typehaus/diff/matcher.py +118 -0
- typehaus/diff/model.py +29 -0
- typehaus/diff/report.py +171 -0
- typehaus/diff/semantic.py +408 -0
- typehaus/diff/variants.py +167 -0
- typehaus/emit/__init__.py +0 -0
- typehaus/emit/draw/__init__.py +38 -0
- typehaus/emit/draw/_shared.py +151 -0
- typehaus/emit/draw/card.py +135 -0
- typehaus/emit/draw/detail_components/__init__.py +215 -0
- typehaus/emit/draw/detail_components/below_grade.py +162 -0
- typehaus/emit/draw/detail_components/breezeway.py +226 -0
- typehaus/emit/draw/detail_components/chrome.py +175 -0
- typehaus/emit/draw/detail_components/config.py +365 -0
- typehaus/emit/draw/detail_components/dispatch.py +187 -0
- typehaus/emit/draw/detail_components/eave.py +122 -0
- typehaus/emit/draw/detail_components/geometry.py +288 -0
- typehaus/emit/draw/detail_components/opening.py +164 -0
- typehaus/emit/draw/detail_components/ridge.py +102 -0
- typehaus/emit/draw/detail_components/sauna.py +247 -0
- typehaus/emit/draw/detail_components/shower.py +215 -0
- typehaus/emit/draw/detail_components/stack.py +152 -0
- typehaus/emit/draw/detail_components/wall_base.py +216 -0
- typehaus/emit/draw/details.py +772 -0
- typehaus/emit/draw/door_symbols.py +320 -0
- typehaus/emit/draw/dxf_writer.py +266 -0
- typehaus/emit/draw/electricalplan.py +77 -0
- typehaus/emit/draw/elevation.py +283 -0
- typehaus/emit/draw/floorplan.py +216 -0
- typehaus/emit/draw/foundation_schedule.py +391 -0
- typehaus/emit/draw/foundationplan.py +312 -0
- typehaus/emit/draw/framing_schedule.py +295 -0
- typehaus/emit/draw/framingplan.py +196 -0
- typehaus/emit/draw/hvacplan.py +89 -0
- typehaus/emit/draw/ir.py +123 -0
- typehaus/emit/draw/joints.py +193 -0
- typehaus/emit/draw/palette.py +190 -0
- typehaus/emit/draw/pdf_writer.py +366 -0
- typehaus/emit/draw/plumbingplan.py +69 -0
- typehaus/emit/draw/render.py +78 -0
- typehaus/emit/draw/roofframingplan.py +251 -0
- typehaus/emit/draw/roofplan.py +31 -0
- typehaus/emit/draw/scene.py +160 -0
- typehaus/emit/draw/schedule_block.py +174 -0
- typehaus/emit/draw/section.py +755 -0
- typehaus/emit/draw/sheets.py +493 -0
- typehaus/emit/draw/siteplan.py +304 -0
- typehaus/emit/draw/structural_common.py +100 -0
- typehaus/emit/gltf/__init__.py +13 -0
- typehaus/emit/gltf/buffers.py +90 -0
- typehaus/emit/gltf/canvas_objects.py +119 -0
- typehaus/emit/gltf/emitter.py +211 -0
- typehaus/emit/gltf/geometry.py +136 -0
- typehaus/emit/gltf/members.py +48 -0
- typehaus/emit/gltf/mesh.py +250 -0
- typehaus/emit/gltf/openings.py +106 -0
- typehaus/emit/gltf/palette.py +218 -0
- typehaus/emit/gltf/roofs.py +236 -0
- typehaus/emit/gltf/scene.py +120 -0
- typehaus/emit/gltf/walls.py +98 -0
- typehaus/emit/ifc/__init__.py +16 -0
- typehaus/emit/ifc/electrical.py +88 -0
- typehaus/emit/ifc/emitter.py +1040 -0
- typehaus/emit/ifc/lowlevel.py +348 -0
- typehaus/emit/ifc/roof.py +325 -0
- typehaus/energy.py +289 -0
- typehaus/findings.py +67 -0
- typehaus/model/__init__.py +239 -0
- typehaus/model/assembly.py +173 -0
- typehaus/model/base.py +48 -0
- typehaus/model/canvas.py +194 -0
- typehaus/model/electrical.py +36 -0
- typehaus/model/elements.py +98 -0
- typehaus/model/enums.py +249 -0
- typehaus/model/floors.py +146 -0
- typehaus/model/ids.py +41 -0
- typehaus/model/materials.py +57 -0
- typehaus/model/mep.py +185 -0
- typehaus/model/patterns.py +14 -0
- typehaus/model/placeable_symbols/__init__.py +99 -0
- typehaus/model/placeable_symbols/_families.py +456 -0
- typehaus/model/placeable_symbols/_frame.py +146 -0
- typehaus/model/placeable_symbols/appliances.py +154 -0
- typehaus/model/placeable_symbols/furniture.py +67 -0
- typehaus/model/placeable_symbols/plumbing.py +243 -0
- typehaus/model/placeables.py +98 -0
- typehaus/model/plan.py +114 -0
- typehaus/model/project.py +85 -0
- typehaus/model/refs.py +95 -0
- typehaus/model/registry.py +90 -0
- typehaus/model/remap.py +158 -0
- typehaus/model/site.py +149 -0
- typehaus/model/spatial.py +172 -0
- typehaus/model/structure.py +275 -0
- typehaus/model/trim.py +146 -0
- typehaus/model/types.py +129 -0
- typehaus/model/views.py +108 -0
- typehaus/quantities/__init__.py +32 -0
- typehaus/quantities/_base.py +77 -0
- typehaus/quantities/angle.py +133 -0
- typehaus/quantities/area.py +84 -0
- typehaus/quantities/length.py +184 -0
- typehaus/quantities/point.py +72 -0
- typehaus/quantities/thermal.py +188 -0
- typehaus/resolve/__init__.py +26 -0
- typehaus/resolve/accessories.py +337 -0
- typehaus/resolve/construction.py +390 -0
- typehaus/resolve/envelope.py +446 -0
- typehaus/resolve/floor_heat.py +45 -0
- typehaus/resolve/floors.py +307 -0
- typehaus/resolve/framing/__init__.py +39 -0
- typehaus/resolve/framing/backing.py +86 -0
- typehaus/resolve/framing/corners.py +125 -0
- typehaus/resolve/framing/footprint.py +66 -0
- typehaus/resolve/framing/openings.py +282 -0
- typehaus/resolve/framing/profiles.py +157 -0
- typehaus/resolve/framing/roof.py +441 -0
- typehaus/resolve/framing/roof_gable.py +253 -0
- typehaus/resolve/framing/solver.py +409 -0
- typehaus/resolve/framing/stud_module.py +98 -0
- typehaus/resolve/framing/tables.py +172 -0
- typehaus/resolve/geometry.py +84 -0
- typehaus/resolve/mep.py +263 -0
- typehaus/resolve/model.py +538 -0
- typehaus/resolve/orientation.py +274 -0
- typehaus/resolve/pipeline.py +270 -0
- typehaus/resolve/placeable_clear_floor_obstruction.py +129 -0
- typehaus/resolve/placeable_groups.py +94 -0
- typehaus/resolve/placeables.py +312 -0
- typehaus/resolve/platform.py +103 -0
- typehaus/resolve/roof_edge.py +195 -0
- typehaus/resolve/roof_edge_geometry.py +286 -0
- typehaus/resolve/roof_geometry.py +184 -0
- typehaus/resolve/roof_layer_setbacks.py +175 -0
- typehaus/resolve/roof_trim.py +379 -0
- typehaus/resolve/rooms.py +84 -0
- typehaus/resolve/site_earth.py +66 -0
- typehaus/resolve/solar.py +91 -0
- typehaus/resolve/stacking.py +118 -0
- typehaus/resolve/stairs/__init__.py +23 -0
- typehaus/resolve/stairs/bearing.py +265 -0
- typehaus/resolve/stairs/common.py +83 -0
- typehaus/resolve/stairs/dispatch.py +197 -0
- typehaus/resolve/stairs/straight.py +53 -0
- typehaus/resolve/stairs/u_split.py +150 -0
- typehaus/resolve/stairs/winder.py +144 -0
- typehaus/resolve/topology.py +697 -0
- typehaus/resolve/vent_termination.py +62 -0
- typehaus/server/__init__.py +0 -0
- typehaus/server/app.py +376 -0
- typehaus/server/building_height_summary.py +76 -0
- typehaus/server/events.py +32 -0
- typehaus/server/macros_api.py +205 -0
- typehaus/server/model_json.py +562 -0
- typehaus/server/space_summary.py +53 -0
- typehaus/server/state.py +370 -0
- typehaus/source/__init__.py +43 -0
- typehaus/source/assembly_ops.py +133 -0
- typehaus/source/bundle.py +328 -0
- typehaus/source/coordinator.py +240 -0
- typehaus/source/detail_ops.py +79 -0
- typehaus/source/dialect.py +219 -0
- typehaus/source/fmt.py +69 -0
- typehaus/source/imported_furniture.py +86 -0
- typehaus/source/imports.py +141 -0
- typehaus/source/imports_py.py +105 -0
- typehaus/source/inmemory.py +178 -0
- typehaus/source/journal.py +60 -0
- typehaus/source/loader.py +397 -0
- typehaus/source/macros.py +871 -0
- typehaus/source/ops.py +194 -0
- typehaus/source/placeable_import.py +397 -0
- typehaus/source/placeables.py +166 -0
- typehaus/source/provenance.py +79 -0
- typehaus/source/serialize.py +105 -0
- typehaus/source/writeback.py +387 -0
- typehaus/source/writeback_py.py +364 -0
- typehaus/takeoff/__init__.py +64 -0
- typehaus/takeoff/anchors.py +258 -0
- typehaus/takeoff/bom.py +71 -0
- typehaus/takeoff/electrical.py +216 -0
- typehaus/takeoff/fasteners.py +171 -0
- typehaus/takeoff/framing.py +234 -0
- typehaus/takeoff/glazing.py +152 -0
- typehaus/takeoff/hangers.py +167 -0
- typehaus/takeoff/hardware.py +36 -0
- typehaus/takeoff/hardware_catalog.py +146 -0
- typehaus/takeoff/hardware_config.py +130 -0
- typehaus/takeoff/placeables.py +53 -0
- typehaus/takeoff/plan_geometry.py +57 -0
- typehaus-0.1.0a0.dist-info/METADATA +93 -0
- typehaus-0.1.0a0.dist-info/RECORD +239 -0
- typehaus-0.1.0a0.dist-info/WHEEL +4 -0
- typehaus-0.1.0a0.dist-info/entry_points.txt +5 -0
typehaus/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Type:Haus engine — typed declarative residential house modeling.
|
|
2
|
+
|
|
3
|
+
The public authoring surface (everything the plan dialect may import) is re-exported
|
|
4
|
+
here from ``typehaus.model``. Plan files import from ``typehaus`` and ``library``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typehaus._meta import PROJECT_NAME, engine_version
|
|
10
|
+
from typehaus.model import * # noqa: F401,F403 — re-export the authoring surface
|
|
11
|
+
from typehaus.model import __all__ as _model_all
|
|
12
|
+
|
|
13
|
+
__version__ = engine_version()
|
|
14
|
+
|
|
15
|
+
__all__ = ["PROJECT_NAME", "engine_version", "__version__", *_model_all]
|
typehaus/_meta.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Brand-name and build-identity constants, centralized for cheap rename (→ 01 §Naming).
|
|
2
|
+
|
|
3
|
+
A rename touches only this file, the ``pyproject.toml`` ``name``, the ``src/typehaus``
|
|
4
|
+
directory, and the UI ``branding.ts`` — see ``docs/RENAME.md``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
PROJECT_NAME = "Type:Haus"
|
|
12
|
+
PROJECT_SLUG = "typehaus"
|
|
13
|
+
PROJECT_URL = "https://type-haus.com"
|
|
14
|
+
CLI_NAME = "haus"
|
|
15
|
+
|
|
16
|
+
# IFC header application name and pset prefix. The prefix is kept short and
|
|
17
|
+
# brand-agnostic so the emitted IFC does not churn on a rename (→ 01 §Naming).
|
|
18
|
+
IFC_APP_NAME = "Type:Haus"
|
|
19
|
+
PSET_PREFIX = "Pset_TH"
|
|
20
|
+
PSET_SOURCE = f"{PSET_PREFIX}_Source"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def engine_version() -> str:
|
|
24
|
+
"""Installed engine version, or a dev sentinel when running from a source tree."""
|
|
25
|
+
try:
|
|
26
|
+
return version(PROJECT_SLUG)
|
|
27
|
+
except PackageNotFoundError:
|
|
28
|
+
return "0.0.0+dev"
|
typehaus/analysis.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Model-level analysis helpers shared by the card and the checks (R-value, etc.).
|
|
2
|
+
|
|
3
|
+
Tri-state honest (#32): a computation missing an input reports UNKNOWN with the material
|
|
4
|
+
named, never a silent zero or a fabricated pass.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from typehaus.model.assembly import Assembly
|
|
12
|
+
from typehaus.model.enums import LayerFunction
|
|
13
|
+
from typehaus.model.plan import Library
|
|
14
|
+
from typehaus.quantities import RValue, rsi
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class RValueResult:
|
|
19
|
+
value: RValue | None # None => UNKNOWN
|
|
20
|
+
unknown_materials: tuple[str, ...] # layer/material names lacking r_per_inch
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def known(self) -> bool:
|
|
24
|
+
return self.value is not None
|
|
25
|
+
|
|
26
|
+
def fmt(self) -> str:
|
|
27
|
+
if self.value is None:
|
|
28
|
+
missing = ", ".join(self.unknown_materials)
|
|
29
|
+
return f"UNKNOWN (missing r_per_inch: {missing})"
|
|
30
|
+
return self.value.fmt()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Still-air surface film resistances (RSI), interior + exterior, ASHRAE nominal.
|
|
34
|
+
_FILM_RSI = 0.12 + 0.03
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _layer_rsi(layer, library: Library, unknown: list[str]) -> float:
|
|
38
|
+
"""Series RSI of one layer, parallel-pathed across its cavity fill if it has one.
|
|
39
|
+
|
|
40
|
+
A batt between studs is *not* in series with the studs — it is the other path through
|
|
41
|
+
the same depth. Summing both (which a sibling batt layer used to do) overstates a 2x6
|
|
42
|
+
wall by roughly the full cavity R. Area-weighted U per ASHRAE parallel-path:
|
|
43
|
+
``U = ff*U_framing + (1-ff)*U_fill``.
|
|
44
|
+
"""
|
|
45
|
+
from typehaus.quantities import r_us
|
|
46
|
+
|
|
47
|
+
mat = library.material(layer.material_ref)
|
|
48
|
+
if mat is None or mat.r_per_inch is None:
|
|
49
|
+
unknown.append(f"{layer.name}({layer.material_ref})")
|
|
50
|
+
return 0.0
|
|
51
|
+
framing_rsi = r_us(mat.r_per_inch * layer.thickness.inches).rsi
|
|
52
|
+
|
|
53
|
+
fill = getattr(layer, "cavity", None)
|
|
54
|
+
if fill is None:
|
|
55
|
+
return framing_rsi
|
|
56
|
+
|
|
57
|
+
fill_mat = library.material(fill.material_ref)
|
|
58
|
+
if fill_mat is None or fill_mat.r_per_inch is None:
|
|
59
|
+
unknown.append(f"{layer.name}-cavity({fill.material_ref})")
|
|
60
|
+
return 0.0
|
|
61
|
+
fill_thickness = fill.thickness if fill.thickness is not None else layer.thickness
|
|
62
|
+
fill_rsi = r_us(fill_mat.r_per_inch * fill_thickness.inches).rsi
|
|
63
|
+
# A shallower fill leaves still air in the rest of the bay; count only the fill so the
|
|
64
|
+
# result stays conservative rather than crediting an unsealed void.
|
|
65
|
+
if framing_rsi <= 0.0 or fill_rsi <= 0.0:
|
|
66
|
+
return max(framing_rsi, 0.0)
|
|
67
|
+
ff = min(max(fill.framing_factor, 0.0), 1.0)
|
|
68
|
+
u_eff = ff / framing_rsi + (1.0 - ff) / fill_rsi
|
|
69
|
+
return 1.0 / u_eff if u_eff > 0.0 else framing_rsi
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def assembly_r_value(asm: Assembly, library: Library, include_lining: bool = True,
|
|
73
|
+
include_films: bool = True) -> RValueResult:
|
|
74
|
+
"""Compute core (+ default_lining) R-value from material r_per_inch (#34)."""
|
|
75
|
+
total_rsi = _FILM_RSI if include_films else 0.0
|
|
76
|
+
unknown: list[str] = []
|
|
77
|
+
layers = list(asm.layers)
|
|
78
|
+
if include_lining:
|
|
79
|
+
layers = list(asm.default_lining) + layers
|
|
80
|
+
for layer in layers:
|
|
81
|
+
total_rsi += _layer_rsi(layer, library, unknown)
|
|
82
|
+
if unknown:
|
|
83
|
+
return RValueResult(value=None, unknown_materials=tuple(unknown))
|
|
84
|
+
return RValueResult(value=rsi(total_rsi), unknown_materials=())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# The framing solver's o.c. default when a STRUCTURE layer's FramingSpec leaves it open;
|
|
88
|
+
# reported so the delta compare never shows a blank spacing for an authored-by-omission wall.
|
|
89
|
+
DEFAULT_FRAMING_SPACING_IN = 16.0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class AssemblyMetrics:
|
|
94
|
+
"""The comparable facts of one assembly — the delta-compare row's data (#53, → 21b).
|
|
95
|
+
|
|
96
|
+
Model-free like the section card itself: everything here reads off the authored
|
|
97
|
+
``Assembly``, so two candidate assemblies can be weighed against each other before
|
|
98
|
+
either is placed on a wall. ``stc`` stays None unless the assembly carries a lab test
|
|
99
|
+
(#50) — an acoustic number is never computed.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
tag: str
|
|
103
|
+
r_value: RValueResult
|
|
104
|
+
thickness_in: float
|
|
105
|
+
layer_count: int
|
|
106
|
+
structure_member: str | None # nominal lumber size of the STRUCTURE layer, e.g. "2x6"
|
|
107
|
+
framing_spacing_in: float | None # o.c. of that layer's framing
|
|
108
|
+
stc: int | None
|
|
109
|
+
variant_of: str | None
|
|
110
|
+
|
|
111
|
+
def as_dict(self) -> dict:
|
|
112
|
+
return {
|
|
113
|
+
"tag": self.tag,
|
|
114
|
+
"r_value": (round(self.r_value.value.r_us, 3)
|
|
115
|
+
if self.r_value.value is not None else None),
|
|
116
|
+
"r_value_unknown_materials": list(self.r_value.unknown_materials),
|
|
117
|
+
"thickness_in": round(self.thickness_in, 4),
|
|
118
|
+
"layer_count": self.layer_count,
|
|
119
|
+
"structure_member": self.structure_member,
|
|
120
|
+
"framing_spacing_in": self.framing_spacing_in,
|
|
121
|
+
"stc": self.stc,
|
|
122
|
+
"variant_of": self.variant_of,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def assembly_metrics(asm: Assembly, library: Library) -> AssemblyMetrics:
|
|
127
|
+
"""Roll one assembly up to the numbers a designer compares alternatives on.
|
|
128
|
+
|
|
129
|
+
Thickness sums the whole built stack (default lining + core) because that is what the
|
|
130
|
+
hallway loses; cavity fill is deliberately excluded — it lives *inside* its host layer's
|
|
131
|
+
depth (→ :class:`CavityFill`) and adding it would double-count the wall.
|
|
132
|
+
"""
|
|
133
|
+
stack = list(asm.default_lining) + list(asm.layers)
|
|
134
|
+
structure = next((ly for ly in stack if ly.function is LayerFunction.STRUCTURE), None)
|
|
135
|
+
spec = structure.framing if structure is not None else None
|
|
136
|
+
spacing_in = None
|
|
137
|
+
if spec is not None:
|
|
138
|
+
spacing_in = (spec.spacing.inches if spec.spacing is not None
|
|
139
|
+
else DEFAULT_FRAMING_SPACING_IN)
|
|
140
|
+
return AssemblyMetrics(
|
|
141
|
+
tag=asm.tag,
|
|
142
|
+
r_value=assembly_r_value(asm, library),
|
|
143
|
+
thickness_in=sum(layer.thickness.inches for layer in stack),
|
|
144
|
+
layer_count=len(stack),
|
|
145
|
+
structure_member=spec.member if spec is not None else None,
|
|
146
|
+
framing_spacing_in=spacing_in,
|
|
147
|
+
stc=asm.stc,
|
|
148
|
+
variant_of=asm.variant_of,
|
|
149
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Checks framework — one registry, two invokers (pytest + `haus check`) (→ 12).
|
|
2
|
+
|
|
3
|
+
Importing this package imports every tier module, registering all checks.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typehaus.checks import advisory, building_science, integrity, mep, structural # noqa: F401 - register
|
|
9
|
+
from typehaus.checks.code import mn_energy, mn_residential, site # noqa: F401 - register
|
|
10
|
+
from typehaus.checks.registry import (
|
|
11
|
+
CheckContext,
|
|
12
|
+
CheckReport,
|
|
13
|
+
FramingPreferences,
|
|
14
|
+
PlumbingPreferences,
|
|
15
|
+
ReferenceUnderlay,
|
|
16
|
+
JurisdictionProfile,
|
|
17
|
+
Preferences,
|
|
18
|
+
Tier,
|
|
19
|
+
check,
|
|
20
|
+
registered,
|
|
21
|
+
run_checks,
|
|
22
|
+
)
|
|
23
|
+
from typehaus.checks.run import build_context, load_preferences, run, run_from_model
|
|
24
|
+
from typehaus.checks.permit import PermitChecklist, PermitChecklistItem, evaluate_permit_checklist
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"run", "run_from_model", "build_context", "load_preferences", "run_checks", "registered", "check",
|
|
28
|
+
"CheckContext", "CheckReport", "Preferences", "FramingPreferences", "PlumbingPreferences", "ReferenceUnderlay", "JurisdictionProfile", "Tier",
|
|
29
|
+
"PermitChecklist", "PermitChecklistItem", "evaluate_permit_checklist",
|
|
30
|
+
]
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Advisory checks — design intelligence, warn-only, reasoning shown (→ 12 §checks/advisory).
|
|
2
|
+
|
|
3
|
+
Opinions with arithmetic behind them, never authority. Each finding states *why* and is
|
|
4
|
+
individually suppressible in preferences.toml.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typehaus.checks.registry import CheckContext, Tier, check
|
|
10
|
+
from typehaus.findings import Finding, Result, Severity
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _warn(cid: str, msg: str, tags: tuple[str, ...] = ()) -> Finding:
|
|
14
|
+
return Finding(severity=Severity.WARN, check_id=cid, message=msg, element_tags=tags,
|
|
15
|
+
result=Result.FAIL)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@check(Tier.ADVISORY, "advisory.habitable_window")
|
|
19
|
+
def habitable_room_window(ctx: CheckContext) -> list[Finding]:
|
|
20
|
+
"""Habitable rooms should have an exterior window (natural light, R303)."""
|
|
21
|
+
from typehaus.model.enums import Occupancy
|
|
22
|
+
|
|
23
|
+
habitable = {Occupancy.BEDROOM, Occupancy.LIVING, Occupancy.KITCHEN,
|
|
24
|
+
Occupancy.DINING, Occupancy.OFFICE}
|
|
25
|
+
out: list[Finding] = []
|
|
26
|
+
has_window = any(not op.is_door for op in ctx.model.openings)
|
|
27
|
+
for room in (e for e in ctx.plan.all_elements() if e.element_kind == "Room"):
|
|
28
|
+
if room.occupancy in habitable and not has_window:
|
|
29
|
+
out.append(_warn("advisory.habitable_window",
|
|
30
|
+
f"habitable room {room.tag} appears to have no window — "
|
|
31
|
+
"consider natural light/ventilation", (room.tag,)))
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@check(Tier.ADVISORY, "advisory.window_size_variety")
|
|
36
|
+
def window_size_variety(ctx: CheckContext) -> list[Finding]:
|
|
37
|
+
"""Fewer unique window sizes = cheaper ordering (reported as a fact with a histogram)."""
|
|
38
|
+
sizes: dict[tuple[float, float], int] = {}
|
|
39
|
+
for op in ctx.model.openings:
|
|
40
|
+
if not op.is_door:
|
|
41
|
+
key = (round(op.width_m, 3), round(op.height_m, 3))
|
|
42
|
+
sizes[key] = sizes.get(key, 0) + 1
|
|
43
|
+
if len(sizes) <= 1:
|
|
44
|
+
return []
|
|
45
|
+
hist = ", ".join(f"{w*39.37:.0f}x{h*39.37:.0f}\"×{n}" for (w, h), n in sizes.items())
|
|
46
|
+
return [_warn("advisory.window_size_variety",
|
|
47
|
+
f"{len(sizes)} unique window sizes — fewer eases ordering ({hist})")]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@check(Tier.ADVISORY, "advisory.control_continuity")
|
|
51
|
+
def control_layer_continuity(ctx: CheckContext) -> list[Finding]:
|
|
52
|
+
"""Warn where a tagged control layer dead-ends at a condition whose transition does
|
|
53
|
+
not declare continuity (walks junctions and stack edges, → 11b)."""
|
|
54
|
+
out: list[Finding] = []
|
|
55
|
+
transitions = ctx.plan.library.transitions
|
|
56
|
+
for cond in ctx.model.conditions:
|
|
57
|
+
if cond.kind.value not in ("storey_stack", "assembly_change"):
|
|
58
|
+
continue
|
|
59
|
+
declared = any(
|
|
60
|
+
_matches(t.condition_pattern, cond.key) and t.continuity for t in transitions
|
|
61
|
+
)
|
|
62
|
+
if not declared:
|
|
63
|
+
out.append(_warn("advisory.control_continuity",
|
|
64
|
+
f"control-layer continuity not declared across {cond.key}",
|
|
65
|
+
cond.element_tags))
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@check(Tier.ADVISORY, "advisory.wet_wall_depth")
|
|
70
|
+
def wet_wall_depth(ctx: CheckContext) -> list[Finding]:
|
|
71
|
+
"""Drain fixtures need a real service chase, not an assumed 2x4-wall stack."""
|
|
72
|
+
from typehaus.model.enums import Service
|
|
73
|
+
|
|
74
|
+
types = {item.tag: item for item in ctx.plan.library.fixture_types}
|
|
75
|
+
required = ctx.preferences.plumbing.drain_stack_required_structure_in * 0.0254
|
|
76
|
+
out: list[Finding] = []
|
|
77
|
+
for fixture in (element for element in ctx.plan.all_elements()
|
|
78
|
+
if element.element_kind == "Fixture"):
|
|
79
|
+
fixture_type = types.get(fixture.type_ref)
|
|
80
|
+
if fixture_type is None or Service.DRAIN not in fixture_type.needs:
|
|
81
|
+
continue
|
|
82
|
+
if fixture.wall_ref is None:
|
|
83
|
+
out.append(_warn("advisory.wet_wall_depth",
|
|
84
|
+
f"drain fixture {fixture.tag} has no wall_ref for a service chase",
|
|
85
|
+
(fixture.tag,)))
|
|
86
|
+
continue
|
|
87
|
+
wall = ctx.model.wall(fixture.wall_ref)
|
|
88
|
+
if wall is None:
|
|
89
|
+
out.append(_warn("advisory.wet_wall_depth",
|
|
90
|
+
f"drain fixture {fixture.tag} references missing wall {fixture.wall_ref}",
|
|
91
|
+
(fixture.tag, fixture.wall_ref)))
|
|
92
|
+
continue
|
|
93
|
+
structure = next((layer for layer in wall.layers if layer.function == "structure"), None)
|
|
94
|
+
if structure is None or structure.thickness_m + 1e-9 < required:
|
|
95
|
+
actual = structure.thickness_m / 0.0254 if structure is not None else 0.0
|
|
96
|
+
out.append(_warn(
|
|
97
|
+
"advisory.wet_wall_depth",
|
|
98
|
+
f"drain fixture {fixture.tag} uses {fixture.wall_ref} with {actual:.1f}\" structure; "
|
|
99
|
+
f"planning allowance requires {required / 0.0254:.1f}\" for its drain stack",
|
|
100
|
+
(fixture.tag, fixture.wall_ref),
|
|
101
|
+
))
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@check(Tier.ADVISORY, "advisory.fixture_room_unassigned")
|
|
106
|
+
def fixture_room_unassigned(ctx: CheckContext) -> list[Finding]:
|
|
107
|
+
"""A permit fixture schedule lists a room per fixture, so an unassignable one is worth
|
|
108
|
+
saying out loud. This is the finding that replaced ``Fixture.room`` being a required
|
|
109
|
+
string: a drag that lands a fixture outside every resolvable room stays loadable and
|
|
110
|
+
keeps its authored position, and the gap is reported here instead of at import time."""
|
|
111
|
+
return [_warn("advisory.fixture_room_unassigned",
|
|
112
|
+
f"fixture {obj.tag} is in no room — the fixture schedule needs one; move it "
|
|
113
|
+
"inside a room or extend the room boundary", (obj.tag,))
|
|
114
|
+
for obj in ctx.model.canvas_objects
|
|
115
|
+
if obj.kind == "Fixture" and obj.room is None]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@check(Tier.ADVISORY, "advisory.floor_heat_fixture_keepout")
|
|
119
|
+
def floor_heat_fixture_keepout(ctx: CheckContext) -> list[Finding]:
|
|
120
|
+
"""Radiant wire zones must not run beneath a fixture's authored footprint."""
|
|
121
|
+
from shapely.geometry import Polygon, box
|
|
122
|
+
|
|
123
|
+
types = {item.tag: item for item in ctx.plan.library.fixture_types}
|
|
124
|
+
fixtures = [
|
|
125
|
+
(storey.tag, element)
|
|
126
|
+
for storey in ctx.plan.storeys
|
|
127
|
+
for element in ctx.plan.storey_elements(storey.tag)
|
|
128
|
+
if element.element_kind == "Fixture"
|
|
129
|
+
]
|
|
130
|
+
out: list[Finding] = []
|
|
131
|
+
for zone in ctx.model.floor_heat:
|
|
132
|
+
zone_polygon = Polygon(zone.zone)
|
|
133
|
+
for storey, fixture in fixtures:
|
|
134
|
+
if storey != zone.storey or fixture.type_ref not in types:
|
|
135
|
+
continue
|
|
136
|
+
width, depth = (value.meters for value in types[fixture.type_ref].footprint)
|
|
137
|
+
x, y = fixture.position.xy_m
|
|
138
|
+
footprint = box(x - width / 2, y - depth / 2, x + width / 2, y + depth / 2)
|
|
139
|
+
if zone_polygon.intersects(footprint):
|
|
140
|
+
out.append(_warn(
|
|
141
|
+
"advisory.floor_heat_fixture_keepout",
|
|
142
|
+
f"floor-heat zone {zone.tag} overlaps fixture {fixture.tag}; "
|
|
143
|
+
"exclude the fixture footprint from the heating loop",
|
|
144
|
+
(zone.tag, fixture.tag),
|
|
145
|
+
))
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@check(Tier.ADVISORY, "advisory.clearance_overlap")
|
|
150
|
+
def clearance_overlap(ctx: CheckContext) -> list[Finding]:
|
|
151
|
+
"""Surface authored fixture/furniture use-zone conflicts in both CLI and canvas.
|
|
152
|
+
|
|
153
|
+
Clearances are intentionally a planning envelope, not a code-compliance calculation:
|
|
154
|
+
rotations and wall-side semantics remain authored decisions. A bounding envelope catches
|
|
155
|
+
the high-value case—another placed object occupies a declared use zone—without quietly
|
|
156
|
+
asserting that every item has a universal code clearance.
|
|
157
|
+
"""
|
|
158
|
+
from shapely.geometry import box
|
|
159
|
+
|
|
160
|
+
fixtures = {item.tag: item for item in ctx.plan.library.fixture_types}
|
|
161
|
+
furniture = {item.tag: item for item in ctx.plan.library.furniture_types}
|
|
162
|
+
placed: list[tuple[str, object, tuple[float, float], tuple[float, float, float, float] | None]] = []
|
|
163
|
+
for storey in ctx.plan.storeys:
|
|
164
|
+
for element in ctx.plan.storey_elements(storey.tag):
|
|
165
|
+
if element.element_kind == "Fixture":
|
|
166
|
+
item = fixtures.get(element.type_ref)
|
|
167
|
+
elif element.element_kind == "Furniture":
|
|
168
|
+
item = furniture.get(element.type_ref)
|
|
169
|
+
else:
|
|
170
|
+
continue
|
|
171
|
+
if item is None:
|
|
172
|
+
continue
|
|
173
|
+
footprint = tuple(value.meters for value in item.footprint)
|
|
174
|
+
clearance = (tuple(value.meters for value in item.clearance)
|
|
175
|
+
if item.clearance is not None else None)
|
|
176
|
+
placed.append((storey.tag, element, footprint, clearance))
|
|
177
|
+
|
|
178
|
+
out: list[Finding] = []
|
|
179
|
+
reported: set[tuple[str, str]] = set()
|
|
180
|
+
for storey, source, footprint, clearance in placed:
|
|
181
|
+
if clearance is None:
|
|
182
|
+
continue
|
|
183
|
+
front, back, left, right = clearance
|
|
184
|
+
x, y = source.position.xy_m
|
|
185
|
+
zone = box(x - footprint[0] / 2 - left, y - footprint[1] / 2 - back,
|
|
186
|
+
x + footprint[0] / 2 + right, y + footprint[1] / 2 + front)
|
|
187
|
+
for other_storey, other, other_footprint, _ in placed:
|
|
188
|
+
if other_storey != storey or other.uid == source.uid:
|
|
189
|
+
continue
|
|
190
|
+
ox, oy = other.position.xy_m
|
|
191
|
+
other_box = box(ox - other_footprint[0] / 2, oy - other_footprint[1] / 2,
|
|
192
|
+
ox + other_footprint[0] / 2, oy + other_footprint[1] / 2)
|
|
193
|
+
key = tuple(sorted((source.tag, other.tag)))
|
|
194
|
+
if zone.intersects(other_box) and key not in reported:
|
|
195
|
+
reported.add(key)
|
|
196
|
+
out.append(_warn(
|
|
197
|
+
"advisory.clearance_overlap",
|
|
198
|
+
f"declared clearance for {source.tag} overlaps {other.tag}; review the use zone",
|
|
199
|
+
key,
|
|
200
|
+
))
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _polygon_centroid(polygon) -> tuple[float, float] | None:
|
|
205
|
+
"""Vertex mean — enough to locate a layer's thin rectangle relative to its neighbours."""
|
|
206
|
+
if not polygon:
|
|
207
|
+
return None
|
|
208
|
+
return (sum(p[0] for p in polygon) / len(polygon),
|
|
209
|
+
sum(p[1] for p in polygon) / len(polygon))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _cladding_offset(wall) -> tuple[float, float] | None:
|
|
213
|
+
"""Innermost layer → cladding layer, i.e. the direction this wall calls "outdoors".
|
|
214
|
+
|
|
215
|
+
``None`` for walls with nothing to orient: a single-layer or uncladded assembly is
|
|
216
|
+
symmetric, so no winding can put it on backwards.
|
|
217
|
+
"""
|
|
218
|
+
layers = wall.depth_layers()
|
|
219
|
+
cladding = next((layer for layer in reversed(layers) if layer.function == "cladding"), None)
|
|
220
|
+
if cladding is None or len(layers) < 2 or cladding is layers[0]:
|
|
221
|
+
return None
|
|
222
|
+
inner = _polygon_centroid(layers[0].polygon)
|
|
223
|
+
outer = _polygon_centroid(cladding.polygon)
|
|
224
|
+
if inner is None or outer is None:
|
|
225
|
+
return None
|
|
226
|
+
return (outer[0] - inner[0], outer[1] - inner[1])
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@check(Tier.ADVISORY, "advisory.cladding_side_mismatch")
|
|
230
|
+
def cladding_side_mismatch(ctx: CheckContext) -> list[Finding]:
|
|
231
|
+
"""Adjacent clad walls must put their cladding on the same side of the run they form.
|
|
232
|
+
|
|
233
|
+
A wall's layers are laid out interior→exterior along its authored start→end direction
|
|
234
|
+
(times the storey's outward sign, → resolve/orientation), so authoring one wall of a run
|
|
235
|
+
backwards silently turns its stack inside out — face brick pointing at the porch, stucco
|
|
236
|
+
at the weather. Nothing downstream notices, because each wall is individually well-formed
|
|
237
|
+
and the storey still has a single outward sign. Comparing neighbours across a shared node
|
|
238
|
+
catches it without having to decide which side of the run is outdoors: walk node ``a→n``
|
|
239
|
+
then ``n→b`` and the cladding must sit on the same hand of both legs.
|
|
240
|
+
"""
|
|
241
|
+
import math
|
|
242
|
+
|
|
243
|
+
out: list[Finding] = []
|
|
244
|
+
for storey in ctx.plan.storeys:
|
|
245
|
+
elements = ctx.plan.storey_elements(storey.tag)
|
|
246
|
+
positions = {e.tag: e.position.xy_m for e in elements if e.element_kind == "Node"}
|
|
247
|
+
# node tag -> [(wall tag, far-end node tag, cladding offset)]
|
|
248
|
+
incident: dict[str, list[tuple[str, str, tuple[float, float]]]] = {}
|
|
249
|
+
for element in elements:
|
|
250
|
+
if element.element_kind not in ("Wall", "FoundationWall"):
|
|
251
|
+
continue
|
|
252
|
+
resolved = ctx.model.wall(element.tag)
|
|
253
|
+
if resolved is None:
|
|
254
|
+
continue
|
|
255
|
+
offset = _cladding_offset(resolved)
|
|
256
|
+
if offset is None:
|
|
257
|
+
continue
|
|
258
|
+
a, b = element.start_node, element.end_node
|
|
259
|
+
if a not in positions or b not in positions or a == b:
|
|
260
|
+
continue
|
|
261
|
+
incident.setdefault(a, []).append((element.tag, b, offset))
|
|
262
|
+
incident.setdefault(b, []).append((element.tag, a, offset))
|
|
263
|
+
|
|
264
|
+
for node, legs in sorted(incident.items()):
|
|
265
|
+
# Only an unambiguous two-wall corner/joint has one traversal; at a branch the
|
|
266
|
+
# "same side" question has no single answer, so say nothing rather than guess.
|
|
267
|
+
if len(legs) != 2:
|
|
268
|
+
continue
|
|
269
|
+
(tag_a, far_a, offset_a), (tag_b, far_b, offset_b) = legs
|
|
270
|
+
here = positions[node]
|
|
271
|
+
# Traverse the run far_a -> node -> far_b, then take each leg's cladding hand.
|
|
272
|
+
hands = []
|
|
273
|
+
for (far, offset, incoming) in ((far_a, offset_a, True), (far_b, offset_b, False)):
|
|
274
|
+
direction = ((here[0] - positions[far][0], here[1] - positions[far][1])
|
|
275
|
+
if incoming
|
|
276
|
+
else (positions[far][0] - here[0], positions[far][1] - here[1]))
|
|
277
|
+
length = math.hypot(*direction)
|
|
278
|
+
if length < 1e-9:
|
|
279
|
+
break
|
|
280
|
+
hands.append((direction[0] * offset[1] - direction[1] * offset[0]) / length)
|
|
281
|
+
if len(hands) != 2 or min(abs(h) for h in hands) < 1e-9:
|
|
282
|
+
continue
|
|
283
|
+
if hands[0] * hands[1] < 0.0:
|
|
284
|
+
out.append(_warn(
|
|
285
|
+
"advisory.cladding_side_mismatch",
|
|
286
|
+
f"{tag_a} and {tag_b} meet at {node} but their cladding faces opposite "
|
|
287
|
+
"sides of the run — one of them is authored end-to-start, so its layer "
|
|
288
|
+
"stack is inside out; swap that wall's start_node/end_node",
|
|
289
|
+
(tag_a, tag_b, node),
|
|
290
|
+
))
|
|
291
|
+
return out
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _matches(pattern: str, key: str) -> bool:
|
|
295
|
+
import fnmatch
|
|
296
|
+
|
|
297
|
+
return fnmatch.fnmatch(key, pattern)
|