WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s

History-free snapshot of the private development repository at cbe45c5
(2026-09-09). Code, docs and public catalogue only; no plant or job
knowledge layers, no client or job names (tools/scrub.py clean). The
intro deck is left out of this snapshot because its slide images could
not be checked for job drawings.
This commit is contained in:
2026-09-09 08:32:15 -05:00
commit 8e6ee31596
406 changed files with 223435 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
# -*- coding: utf-8 -*-
"""`tools/panelanalysis_schema.py` -- the manual wind zone contract, the
'Free' restraint filter, and `reinforcement.mild_bars` (PANELANALYSIS-4).
The engine side only. `WillTools.extension/tests/test_panelanalysis_wind.py`
(and its siblings) already cover the front-end's own `build_deck` shape and
the pre-existing legacy wind precedence (direct > computed > blank); this
file does not repeat those, and does not touch anything under
`WillTools.extension/` -- that tree is the front-end sibling's territory.
Import pattern mirrors `_scratch/run_w1.py`: `tools/` goes on `sys.path`,
`deckcheck` is imported first purely for its side effect of registering a
bare `vibe` package stub (`panelanalysis_schema` does `from vibe import
panelanalysisform`, which needs that stub to resolve off Revit), THEN
`panelanalysis_schema` itself.
"""
import os
import sys
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
TOOLS = os.path.abspath(os.path.join(HERE, os.pardir))
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
import deckcheck # noqa: E402,F401 -- registers the `vibe` stub
import panelanalysis_schema as schema # noqa: E402
from panellift import inplace # noqa: E402
from panellift.model import Panel # noqa: E402
def base_deck(**wind_overrides):
deck = schema.new_deck(strands=[])
deck['loading']['wind'] = dict(wind_overrides)
return deck
def wall_panel(length_ft=40.0, width_ft=8.0):
return Panel(mark='TEST', length_ft=length_ft, width_ft=width_ft,
thickness_in=8.0, unit_weight_pcf=150.0, fc_psi=5000.0,
fci_psi=3500.0)
# --------------------------------------------------------------- wind.mode
def test_project_config_mode_refuses_with_the_named_reason():
deck = base_deck(mode='project_config')
zones, trib, reason = schema.wind_zones(deck)
assert zones == []
assert reason == schema.WIND_PROJECT_CONFIG_NOT_BUILT
assert reason == 'wind from project config is not built yet'
def test_project_config_mode_refuses_even_carrying_other_wind_keys():
"""The refusal is unconditional on `mode` alone -- an asce block or a
direct pressure sitting beside it changes nothing."""
deck = base_deck(mode='project_config', direct_pressure_psf=16.0,
asce={'v_ult_mph': 115.0})
zones, _trib, reason = schema.wind_zones(deck)
assert zones == []
assert reason == schema.WIND_PROJECT_CONFIG_NOT_BUILT
def test_manual_mode_two_zone_different_pressure_suction_reaches_the_engine():
"""Two zones with DIFFERENT pressure/suction magnitudes: `wind_zones`
hands `inplace` genuinely different fields, and the deck-level
`in_place_inputs` flags `wind_zone_mode='manual'` so the engine solves
them as two independent cases (not the old negate shortcut)."""
deck = base_deck(
mode='manual',
zones=[
{'label': 'W1', 'x0_ft': 0.0, 'x1_ft': 20.0, 'width_ft': 8.0,
'pressure_psf': 16.0, 'suction_psf': 12.0},
{'label': 'W2', 'x0_ft': 20.0, 'x1_ft': 40.0, 'width_ft': 8.0,
'pressure_psf': 20.0, 'suction_psf': None},
])
panel = wall_panel()
zones, trib, reason = schema.wind_zones(deck, panel)
assert reason is None
assert len(zones) == 2
z1, z2 = zones
assert z1.pressure_psf == 16.0 and z1.suction_psf == 12.0
assert z1.width_ft == 8.0
assert z2.pressure_psf == 20.0 and z2.suction_psf is None
assert schema.wind_zone_mode(deck) == 'manual'
deck['structural_model']['restraints'] = [
{'x_ft': 0.0, 'type': 'Pin'}, {'x_ft': 20.0, 'type': 'Pin'},
{'x_ft': 40.0, 'type': 'Pin'}]
inputs, in_reason = schema.in_place_inputs(deck, panel)
assert in_reason is None
assert inputs.wind_zone_mode == 'manual'
res = inplace.run(panel, inputs)
assert res.refusal is None
# A pure-negate suction field would peak at exactly the pressure peak;
# zone 2 (20 psf pressure, no suction) contributing nothing to W- while
# zone 1 does breaks that equality.
key_xs = res.cases['D'].xs
cases_suction = inplace.build_wind_suction_cases(panel, inputs, key_xs,
res.cases)
assert cases_suction is not None
w_plus_peak = max(abs(m) for m in res.cases['W'].moment_lbft)
w_minus_peak = max(abs(m) for m in cases_suction['W'].moment_lbft)
assert w_minus_peak != pytest.approx(w_plus_peak)
def test_manual_mode_zone_width_is_per_zone_not_panel_wide():
deck = base_deck(
mode='manual',
zones=[{'label': 'W1', 'x0_ft': 0.0, 'x1_ft': 40.0,
'width_ft': 4.2, 'pressure_psf': 10.0, 'suction_psf': 10.0}])
zones, trib, reason = schema.wind_zones(deck, wall_panel())
assert reason is None
assert zones[0].width_ft == 4.2
# trib (the panel-wide fallback) is untouched -- a zone carrying its own
# width does not have to state a deck-wide tributary_width_ft too.
assert trib is None
def test_legacy_zones_deck_is_unaffected_by_the_mode_reader():
"""No `mode` key at all (the golden shape) -- the pre-existing single-
pressure zone reading is byte-for-byte what it always was."""
deck = base_deck(zones=[{'x0_ft': 0.0, 'x1_ft': 37.6,
'pressure_psf': 16.0, 'label': 'W-pressure'}])
zones, trib, reason = schema.wind_zones(deck, wall_panel())
assert reason is None
assert len(zones) == 1
assert zones[0].pressure_psf == 16.0
assert zones[0].suction_psf is None
assert zones[0].width_ft is None
assert schema.wind_zone_mode(deck) == 'legacy'
# ---------------------------------------------------- restraints, 'Free'
def test_free_restraint_is_skipped_others_are_kept():
deck = schema.new_deck()
deck['structural_model']['restraints'] = [
{'x_ft': 0.0, 'type': 'Pin'},
{'x_ft': 10.0, 'type': 'Free'},
{'x_ft': 20.0, 'type': 'Fixed'},
{'x_ft': 30.0, 'type': 'Floor tie'},
]
xs, reason = schema.restraints(deck)
assert reason is None
assert xs == [0.0, 20.0, 30.0]
def test_free_restraint_match_is_case_insensitive():
deck = schema.new_deck()
deck['structural_model']['restraints'] = [
{'x_ft': 0.0, 'type': 'FREE'},
{'x_ft': 10.0, 'type': 'free'},
{'x_ft': 20.0, 'type': 'Pin'},
{'x_ft': 30.0, 'type': 'Pin'},
]
xs, _reason = schema.restraints(deck)
assert xs == [20.0, 30.0]
def test_an_unrecognised_type_word_still_counts_as_lateral():
"""Only the literal word 'Free' is excluded -- an unknown word (the
golden's own 'restrained', or 'dunnage') is counted, never quietly
dropped (CLAUDE.md sec.3: unrecognised never means silently excluded)."""
deck = schema.new_deck()
deck['structural_model']['restraints'] = [
{'x_ft': 0.0, 'type': 'restrained'},
{'x_ft': 10.0, 'type': 'something-nobody-has-seen'},
]
xs, _reason = schema.restraints(deck)
assert xs == [0.0, 10.0]
def test_restraint_rows_carries_every_extra_key_for_the_page():
deck = schema.new_deck()
deck['structural_model']['restraints'] = [
{'x_ft': 5.0, 'type': 'Pin', 'elevation_ft': 102.5, 'gravity': True,
'thermal': False, 'stiffness_kin': 250.0},
{'x_ft': 15.0, 'type': 'Free'},
]
rows = schema.restraint_rows(deck)
assert len(rows) == 2
assert rows[0]['elevation_ft'] == 102.5
assert rows[0]['gravity'] is True
assert rows[0]['thermal'] is False
assert rows[0]['stiffness_kin'] == 250.0
assert rows[0]['lateral'] is True
assert rows[1]['lateral'] is False
# ---------------------------------------------------------- mild_bars
def test_mild_bars_with_depth_enter_the_capacity_blank_depth_does_not():
deck = schema.new_deck(strands=[])
deck['reinforcement']['mild_bars'] = [
{'size': '#4', 'count': 24, 'area_in2': 4.8, 'depth_in': 2.0,
'direction': 'Longitudinal', 'from_ft': 0.0, 'to_ft': 37.6},
{'size': '#3', 'count': 10, 'area_in2': 1.1, 'depth_in': None,
'direction': 'Longitudinal', 'from_ft': 0.0, 'to_ft': 37.6},
{'size': '#4', 'count': 8, 'area_in2': 1.6, 'depth_in': 2.0,
'direction': 'Transverse', 'from_ft': 0.0, 'to_ft': 37.6},
]
bars = schema.mild_bars(deck)
assert len(bars) == 3
assert bars[1]['depth_in'] is None
layers = schema.mild_bar_reinforcement(deck)
# Only the first row: has a depth AND is Longitudinal.
assert len(layers) == 1
assert layers[0].area_in2 == pytest.approx(4.8)
assert layers[0].count == 1 # total already, not per-bar
assert layers[0].z_in == pytest.approx(2.0)
assert layers[0].from_ft == pytest.approx(0.0)
assert layers[0].to_ft == pytest.approx(37.6)
assert layers[0].grade_ksi == pytest.approx(schema.DEFAULT_MILD_BAR_GRADE_KSI)
def test_mild_bars_grade_reads_settings_rebar_fy_ksi():
deck = schema.new_deck(strands=[])
deck['settings']['rebar_fy_ksi'] = 40.0
deck['reinforcement']['mild_bars'] = [
{'size': '#5', 'count': 4, 'area_in2': 1.24, 'depth_in': 3.0,
'direction': 'Longitudinal'}]
layers = schema.mild_bar_reinforcement(deck)
assert layers[0].grade_ksi == pytest.approx(40.0)
def test_no_mild_bars_block_is_the_identity_on_to_panel():
"""A deck with no `reinforcement.mild_bars` at all -- every deck before
PANELANALYSIS-4 -- must reach `to_panel` with EXACTLY the reinforcement
`mild_reinforcement` alone would have produced (here: none), proving the
handling path (pu=0) sees no change for a bar-less deck."""
deck = schema.new_deck(strands=[])
panel = schema.to_panel(deck)
assert panel.reinforcement == []
def test_mild_bars_area_in2_is_the_layer_total_not_per_bar():
"""24 x #4 (area_in2=4.8 TOTAL, per the field model's own convention) must
not be re-multiplied by count into 24 * 4.8."""
deck = schema.new_deck(strands=[])
deck['reinforcement']['mild_bars'] = [
{'size': '#4', 'count': 24, 'area_in2': 4.8, 'depth_in': 2.0,
'direction': 'Longitudinal'}]
panel = schema.to_panel(deck)
assert len(panel.reinforcement) == 1
assert panel.reinforcement[0].total_area_in2 == pytest.approx(4.8)
def test_mild_bars_missing_required_key_refuses_loudly():
deck = schema.new_deck(strands=[])
deck['reinforcement']['mild_bars'] = [{'size': '#4', 'depth_in': 2.0}]
with pytest.raises(schema.DeckError):
schema.mild_bars(deck)