# -*- 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)