8e6ee31596
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.
258 lines
10 KiB
Python
258 lines
10 KiB
Python
# -*- 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)
|