# -*- coding: utf-8 -*- """The In-Place strength model: load cases, combinations, and the checks. Neutral fixtures only -- no client geometry, no client numbers. The parity against the reference tool's real pieces lives in the scratchpad harness; these are the identities any correct in-place answer must satisfy: * a cantilever under a uniform wind gives w*L^2/2 at the support (statics); * a load combination assembles exactly sum(factor * case) and nothing else; * the flexure util is |Mu| / (phi*Mn from capacity_at), reused not rebuilt; * axial phi*Pn(C) is 0.85 f'c Ag * 0.80 * 0.65 for a plain/mild section, and carries the strand reduction where strand is developed; * fewer than two restraints refuses loudly, the engine's own words. """ import math import pytest from panellift import capacity, inplace, section from panellift.model import Panel, Prestress, Strand # ------------------------------------------------------------------ fixtures def plain_wall(length_ft=30.0, width_ft=10.0, thickness_in=12.0, fc_psi=5000.0): """A solid, non-prestressed wall -- the mild-wall shape (no strand).""" return Panel(mark='TEST-WALL', length_ft=length_ft, width_ft=width_ft, thickness_in=thickness_in, unit_weight_pcf=150.0, fc_psi=fc_psi, fci_psi=3500.0, prestress=None) def strand_wall(length_ft=30.0, width_ft=10.0, thickness_in=12.0, fc_psi=5000.0): """A solid wall carrying two straight strands near the tension face.""" strands = [Strand(y=2.0, z_in=2.0, area_in2=0.153, initial_pull_lb=28900.0, label='S1'), Strand(y=8.0, z_in=2.0, area_in2=0.153, initial_pull_lb=28900.0, label='S2')] pre = Prestress(strands=strands, loss_pct=8.0, diameter_in=0.5, fpu_ksi=270.0) return Panel(mark='TEST-STRAND', length_ft=length_ft, width_ft=width_ft, thickness_in=thickness_in, unit_weight_pcf=150.0, fc_psi=fc_psi, fci_psi=3500.0, prestress=pre) def cantilever_inputs(pressure_psf=100.0): """Restraints at 0 and 20 ft, a 10 ft cantilever above with wind on it.""" return inplace.InPlaceInputs( restraint_xs=[0.0, 20.0], wind_zones=[inplace.LateralPressureZone(20.0, 30.0, pressure_psf, 'W cantilever')]) # ------------------------------------------------------- cantilever statics def test_cantilever_moment_is_w_l_squared_over_two(): """A uniform wind on a cantilever gives w*L^2/2 at the support. This is the whole reason the beam reuse is trusted: the reference tool's own governing WPA1.0 flexure moment IS a cantilever moment, and the closed form is unarguable. """ panel = plain_wall() inputs = cantilever_inputs(pressure_psf=100.0) dia, rig = inplace.solve_lateral(panel, inputs.restraint_xs, inputs.wind_zones, panel.width_ft, panel.fc_psi) w_plf = 100.0 * panel.width_ft # 1000 plf cantilever_ft = 10.0 expected = w_plf * cantilever_ft ** 2 / 2.0 # 50,000 lb-ft at_support = dia.moment_at(20.0) assert abs(abs(at_support) - expected) / expected < 0.01 # and the free top closes to zero assert abs(dia.moment_at(30.0)) < 0.02 * expected # --------------------------------------------------- load-case superposition def test_load_case_fields_split_dead_and_wind(): """Dead is axial only; wind is moment/shear only. No crosstalk.""" panel = plain_wall() inputs = cantilever_inputs() xs = inplace.key_stations(panel, inputs) cases = inplace.build_load_cases(panel, inputs, xs) d = cases['D'] w = cases['W'] # Dead carries axial, no moment/shear. assert max(abs(v) for v in d.axial_lb) > 0.0 assert all(v == 0.0 for v in d.moment_lbft) assert all(v == 0.0 for v in d.shear_lb) # Wind carries moment/shear, no axial. assert max(abs(v) for v in w.moment_lbft) > 0.0 assert all(v == 0.0 for v in w.axial_lb) # Dead axial at the base is the whole self weight; at the top it is zero. assert d.axial_at(0.0) > d.axial_at(20.0) > 0.0 assert abs(d.axial_at(panel.length_ft)) < 1.0 def test_combination_demand_is_pure_superposition(): """Mu, Vu, Pu are exactly sum(factor * case) -- LC7 = 0.9D + 1.0W.""" panel = plain_wall() inputs = cantilever_inputs() xs = inplace.key_stations(panel, inputs) cases = inplace.build_load_cases(panel, inputs, xs) lc7 = next(c for c in inplace.ASCE_7_16_STRENGTH if c.name == 'LC7') x = 20.0 pu, mu, vu = inplace.combination_demand(cases, lc7, x) # 0.9 * dead axial, 1.0 * wind moment, and T/H are zero cases. assert pu == pytest.approx(0.9 * cases['D'].axial_at(x), rel=1e-9) assert mu == pytest.approx(1.0 * cases['W'].moment_at(x), rel=1e-9) assert vu == pytest.approx(1.0 * cases['W'].shear_at(x), rel=1e-9) def test_known_aci_combo_assembles_the_right_factored_demand(): """LC5 = 1.2D + 1.0L + 0.5R + 1.0W applies each factor to its own case. Built from a hand-set case table so the arithmetic is unarguable: dead 100k axial, live 20k axial, wind 10 k-ft moment -> LC5 Pu = 1.2*100 + 1.0*20 = 140 k and Mu = 1.0*10 = 10 k-ft. """ xs = [0.0, 1.0] zeros = [0.0, 0.0] cases = { 'D': inplace.LoadField('D', 'D', xs, [100000.0, 100000.0], list(zeros), list(zeros)), 'L': inplace.LoadField('L', 'L', xs, [20000.0, 20000.0], list(zeros), list(zeros)), 'R': inplace.LoadField('R', 'R', xs, [5000.0, 5000.0], list(zeros), list(zeros)), 'W': inplace.LoadField('W', 'W', xs, list(zeros), [10000.0, 10000.0], [3000.0, 3000.0]), } lc5 = next(c for c in inplace.ASCE_7_16_STRENGTH if c.name == 'LC5') pu, mu, vu = inplace.combination_demand(cases, lc5, 0.0) assert pu == pytest.approx(1.2 * 100000 + 1.0 * 20000 + 0.5 * 5000) assert mu == pytest.approx(1.0 * 10000) assert vu == pytest.approx(1.0 * 3000) # ----------------------------------------------------- axial capacity (new) def test_axial_phi_pn_c_plain_section(): """phi*Pn(C) = 0.65 * 0.80 * 0.85 f'c Ag for a plain/mild section.""" panel = plain_wall(width_ft=10.0, thickness_in=12.0, fc_psi=5000.0) stage = inplace.in_place_stage(panel, cantilever_inputs()) ag = section.NetSection(panel, 5.0).area_in2 # 120 in * 12 in ax = inplace.axial_capacity(panel, stage, 5.0, pu_lb=100000.0, restraint_xs=[0.0, 20.0]) expected = (inplace.PHI_AXIAL_COMPRESSION * inplace.AXIAL_TIED_FACTOR * 0.85 * 5000.0 * ag) assert ax.phi_pn_c_lb == pytest.approx(expected, rel=1e-9) assert ax.util == pytest.approx(100000.0 / expected, rel=1e-9) def test_axial_strand_term_reduces_po(): """A developed strand REDUCES Po by (f_se - 0.003 Ep) Aps (docs sec.5.1).""" panel = strand_wall() stage = inplace.in_place_stage(panel, cantilever_inputs()) # A mid-span station where both strands are fully developed. x = 15.0 ax = inplace.axial_capacity(panel, stage, x, pu_lb=50000.0, restraint_xs=[0.0, 20.0]) plain = inplace.PHI_AXIAL_COMPRESSION * inplace.AXIAL_TIED_FACTOR * \ 0.85 * panel.fc_psi * section.NetSection(panel, x).area_in2 # With strand present phi*Pn(C) is LOWER than the plain 0.85 f'c Ag figure. assert ax.aps_in2 > 0.0 assert ax.phi_pn_c_lb < plain def test_axial_reports_pcrit(): """phi*Pcrit is computed (Euler on EI_eff) and flagged approximate.""" panel = plain_wall() stage = inplace.in_place_stage(panel, cantilever_inputs()) ax = inplace.axial_capacity(panel, stage, 10.0, pu_lb=100000.0, restraint_xs=[0.0, 20.0]) assert ax.phi_pcrit_lb is not None and ax.phi_pcrit_lb > 0.0 assert ax.pcrit_note and 'approximate' in ax.pcrit_note # ------------------------------------------------- strength util from capacity def test_flexure_util_comes_from_capacity_at(): """The in-place flexure util is a P-M-aware capacity fed the combo's Mu/Pu. A pure-strand piece (no mild reinforcement) is checked through the additive `capacity.strand_pm_flexure_at` (Eriksson parity gap #2), NOT `capacity_at`'s flat pure-bending phi*Mn -- `_check_station` overwrites `sd.cap` with the P-M numbers when they can be judged. This pins that the util the run reports is that same P-M number, at the SAME station's own Pu, not the flat one. """ panel = strand_wall() inputs = cantilever_inputs(pressure_psf=300.0) # enough to bend it res = inplace.run(panel, inputs) assert res.refusal is None # find a combo/station where flexure was judged found = False for cr in res.combos: for sd in cr.stations: if sd.flex_util is not None: direct = capacity.strand_pm_flexure_at( panel, res.stage, sd.x, sd.mu_lbft, pu_lb=sd.pu_lb) assert sd.flex_util == pytest.approx(direct.m_over_phi_mn) found = True assert found, 'no station produced a flexural util to check' def test_mild_wall_flexure_dashes_not_passes(): """A no-strand wall dashes phi*Mn with a reason -- never a fabricated pass. capacity_at is strand-only; the mild flexural capacity is the concurrent capacity layer's job. The in-place check must surface that as a could-not-judge, exactly like every other dash in the engine. """ panel = plain_wall() inputs = cantilever_inputs(pressure_psf=300.0) res = inplace.run(panel, inputs) combo = res.combos[0] sd = combo.stations[len(combo.stations) // 2] assert sd.flex_util is None assert sd.cap.reason(capacity.PHI_MN) # a reason is present # ---------------------------------------------------------------- refusal def test_fewer_than_two_restraints_refuses_loudly(): """One restraint is not a span -- the engine refuses, in its own words.""" panel = plain_wall() inputs = inplace.InPlaceInputs(restraint_xs=[0.0], wind_zones=[]) res = inplace.run(panel, inputs) assert res.refusal is not None assert 'two supports' in res.refusal assert not res.combos def test_run_finds_an_overall_governing_check(): """A wired run names an overall governing combo/check/util/x.""" panel = strand_wall() res = inplace.run(panel, cantilever_inputs(pressure_psf=300.0)) assert res.gov_combo and res.gov_check and res.gov_util is not None assert res.gov_util > 0.0 # ---------------------------------------------------------- combination set def test_asce_strength_set_matches_the_printed_combinations(): """The strength set is the ASCE 7-16 2.3 combos with Eriksson's T/H.""" names = [c.name for c in inplace.ASCE_7_16_STRENGTH] assert names == ['LC1', 'LC2', 'LC3', 'LC4', 'LC5', 'LC6', 'LC7', 'LC8'] lc1 = inplace.ASCE_7_16_STRENGTH[0] assert lc1.factors['D'] == 1.4 lc7 = next(c for c in inplace.ASCE_7_16_STRENGTH if c.name == 'LC7') assert lc7.factors['D'] == 0.9 and lc7.factors['W'] == 1.0 # every strength combo folds in 1.0T and 1.6H, per docs sec.7 for c in inplace.ASCE_7_16_STRENGTH: assert c.factors.get('T') == 1.0 assert c.factors.get('H') == 1.6 # ------------------------------------------- eccentric bearing loads (gap #1) def test_eccentric_dead_load_puts_a_real_moment_on_the_d_case(): """`AppliedAxial.ecc_in` turns a dead bearing load into a D-case moment. Before this field existed, `dead_axial_field`/`live_axial_field` only ever populated `axial_lb` -- `D`'s `moment_lbft` was all zeros (Eriksson in-place parity gap #1, `_scratch/eriksson_parity_2026-09-04.md` root cause #1). A load with `ecc_in == 0.0` (the default) must still leave D's moment at all zeros -- a deck with no eccentric loads is byte-for-byte what this engine reported before `ecc_in` existed. """ panel = plain_wall(length_ft=40.0) concentric = inplace.InPlaceInputs( restraint_xs=[0.0, 20.0, 40.0], applied_axial=[inplace.AppliedAxial(x_ft=20.0, dead_lb=40000.0, label='P1')]) xs = inplace.key_stations(panel, concentric) cases = inplace.build_load_cases(panel, concentric, xs) assert all(v == 0.0 for v in cases['D'].moment_lbft) eccentric = inplace.InPlaceInputs( restraint_xs=[0.0, 20.0, 40.0], applied_axial=[inplace.AppliedAxial(x_ft=20.0, dead_lb=40000.0, ecc_in=6.0, label='P1')]) cases_ecc = inplace.build_load_cases(panel, eccentric, xs) assert max(abs(v) for v in cases_ecc['D'].moment_lbft) > 0.0 # axial is unchanged by the eccentricity -- same P(x), a real moment on top. assert cases_ecc['D'].axial_lb == cases['D'].axial_lb def test_eccentric_moment_matches_the_point_moment_hand_solution(): """The D-case moment field IS the `PointMoment` beam solve, hand-checked. Two restraints only (determinate), so `solve_point_moments`'s answer must match `PointMoment`'s own closed form exactly, on `eccentric_moments`' OWN `moment_lbft = -(P*ecc_in/12)` (the minus is the golden-pinned sign, see `eccentric_moments`' docstring): with `m0 = -(P*ecc_in/12)` at x=10 ft on a 2-support 20 ft span, R0=-m0/L, RL=+m0/L, and M(x) = -m0*x/L for x<10, m0*(1-x/20) for x>10. """ panel = plain_wall(length_ft=20.0) p_lb, ecc_in = 40000.0, 6.0 m0 = -(p_lb * ecc_in / 12.0) inputs = inplace.InPlaceInputs( restraint_xs=[0.0, 20.0], applied_axial=[inplace.AppliedAxial(x_ft=10.0, dead_lb=p_lb, ecc_in=ecc_in, label='P1')]) xs = sorted(set([2.0, 5.0, 8.0, 12.0, 15.0, 18.0])) cases = inplace.build_load_cases(panel, inputs, xs) d = cases['D'] for x in xs: expected = (-m0 * x / 20.0 if x < 10.0 else m0 * (1.0 - x / 20.0)) assert d.moment_at(x) == pytest.approx(expected, abs=1.0), x def test_eccentric_moment_sign_matches_the_golden_p1_p6_jumps(): """The golden's own Mu jump at P1 and P6 (WPA2.0/A27 pp.30-31), reproduced. P6 (x=15'-11.25"=15.9375 ft, ecc_in=-10 in, D=20.18k, L=12.25k) jumps +38.9 k-ft crossing its own station in the golden's table (14.87 ft: -16.6 -> 16.35 ft: +22.3) -- close to the LC2-factored P*e/12 = (1.2*20.18+1.6*12.25)*10/12 = 36.51 k-ft, POSITIVE for a NEGATIVE offset. P1 (x=11'-1.5"=11.125 ft, ecc_in=+10 in, D=9.62k, L=6.01k) jumps -24.2 k-ft (10.41 ft: +6.5 -> 11.12 ft: -17.7) -- close to -(1.2*9.62+1.6*6.01)*10/12 = -17.63 k-ft (plus the linear slope over the 0.7 ft gap to golden's own pre-jump row), NEGATIVE for a POSITIVE offset. This is what pins `eccentric_moments`' `-(P*ecc_in/12)` sign, checked directly on THIS engine's own D-case (LC2's D factor only, isolating the mechanism from L/W): the D-only jump at each station must have the sign the golden's own jump has. """ panel = plain_wall(length_ft=74.0 + 4.0 / 12.0) p1_x, p1_ecc, p1_d = 11.0 + 1.5 / 12.0, 10.0, 9.62e3 p6_x, p6_ecc, p6_d = 15.0 + 11.25 / 12.0, -10.0, 20.18e3 inputs = inplace.InPlaceInputs( restraint_xs=[0.0, panel.length_ft], applied_axial=[ inplace.AppliedAxial(x_ft=p1_x, dead_lb=p1_d, ecc_in=p1_ecc, label='P1'), inplace.AppliedAxial(x_ft=p6_x, dead_lb=p6_d, ecc_in=p6_ecc, label='P6')]) xs = [0.0, p1_x - 0.5, p1_x + 0.001, p6_x - 0.5, p6_x + 0.001, panel.length_ft] cases = inplace.build_load_cases(panel, inputs, sorted(xs)) d = cases['D'] # P6: negative offset -> POSITIVE jump. assert d.moment_at(p6_x + 0.001) > d.moment_at(p6_x - 0.5) # P1: positive offset -> NEGATIVE jump. assert d.moment_at(p1_x + 0.001) < d.moment_at(p1_x - 0.5) # ------------------------------------------------------ wind envelope (gap #3) def test_wind_envelope_picks_the_worse_of_pressure_and_suction(): """Every check is enveloped over BOTH wind directions (Eriksson gap #3). `ASCE_7_16_STRENGTH` only ever carries a POSITIVE W factor, so without the envelope a station only ever sees pressure. With an eccentric dead bearing load ALSO putting a real moment into the same stations, pressure and suction combine with it differently station to station -- this pins that `inplace.run`'s reported per-station flex/shear utils are exactly the max of a hand-run of BOTH `wind_suction_cases` directions, and that the two directions really do disagree somewhere (otherwise the envelope would be a no-op and this would not be testing anything). """ panel = strand_wall(length_ft=40.0) inputs = inplace.InPlaceInputs( restraint_xs=[0.0, 20.0, 40.0], wind_zones=[inplace.LateralPressureZone(0.0, 40.0, 20.0, 'W')], applied_axial=[inplace.AppliedAxial(x_ft=20.0, dead_lb=40000.0, ecc_in=4.0, label='P1')]) res = inplace.run(panel, inputs) assert res.refusal is None cases_suction = inplace.wind_suction_cases(res.cases) assert cases_suction is not None checked = 0 disagreed = False for cr in res.combos: if not cr.combo.factors.get('W', 0.0): continue for sd in cr.stations: a = inplace._check_station(panel, res.stage, res.cases, cr.combo, sd.x, inputs.restraint_xs) b = inplace._check_station(panel, res.stage, cases_suction, cr.combo, sd.x, inputs.restraint_xs) want_flex = max(a.flex_util if a.flex_util is not None else -1.0, b.flex_util if b.flex_util is not None else -1.0) got_flex = sd.flex_util if sd.flex_util is not None else -1.0 assert got_flex == pytest.approx(want_flex, abs=1e-9) if a.mu_lbft != pytest.approx(b.mu_lbft): disagreed = True checked += 1 assert checked > 0 assert disagreed, ('pressure and suction produced the same Mu everywhere ' '-- the fixture is not exercising the envelope') # --------------------------------------------- LC2 magnitude at a full jump def test_lc2_reproduces_the_golden_p6_jump_at_close_to_full_magnitude(): """`key_stations` forcing a node at (and just before) each load is what lets a combo's factored jump reach close to its full magnitude. WPA2.0/A27's own P6 (x=15'-11.25"=15.9375 ft, ecc_in=-10 in, D=20.18k, L=12.25k) jumps +38.9 k-ft crossing its station under the golden's own LC2 (1.2D+1.6L) -- close to (1.2*20.18+1.6*12.25)*10/12 = 36.51 k-ft. Before `key_stations` forced a node onto every eccentric load's own x (and a hair before it), the coarse D/L `LoadField`s only sampled the beam solve's fine diagram at the L/20 grid, and a jump landing INSIDE one of those coarse intervals got smoothed into a gentle ramp across the whole interval instead of a sharp step -- Mu magnitudes read ~50-60% of the golden's own printed jumps. This runs `inplace.run` end to end (not a hand-built `xs` list) and checks the jump `key_stations` itself produces is within 10% of the LC2-factored P*e/12 (a looser tolerance than the hand-solution tests because this station's own backoff point is a genuine finite distance from the load, not an infinitesimal limit). """ panel = plain_wall(length_ft=74.0 + 4.0 / 12.0, width_ft=4.0, thickness_in=8.0) p6_x = 15.0 + 11.25 / 12.0 inputs = inplace.InPlaceInputs( restraint_xs=[0.0, panel.length_ft], applied_axial=[inplace.AppliedAxial(x_ft=p6_x, dead_lb=20.18e3, live_lb=12.25e3, ecc_in=-10.0, label='P6')]) xs = inplace.key_stations(panel, inputs) cases = inplace.build_load_cases(panel, inputs, xs) lc2 = next(c for c in inplace.ASCE_7_16_STRENGTH if c.name == 'LC2') before = max(x for x in xs if x < p6_x) after = min(x for x in xs if x >= p6_x) _pu_a, mu_a, _vu_a = inplace.combination_demand(cases, lc2, before) _pu_b, mu_b, _vu_b = inplace.combination_demand(cases, lc2, after) jump = mu_b - mu_a expected = (1.2 * 20.18e3 + 1.6 * 12.25e3) * 10.0 / 12.0 assert jump == pytest.approx(expected, rel=0.10), ( 'jump {0:.1f} lb-ft vs the LC2-factored P*e/12 {1:.1f} lb-ft' .format(jump, expected)) # ------------------------------------------------- manual per-zone wind (PANELANALYSIS-4) def test_manual_zones_pressure_and_suction_give_different_w_fields(): """A zone with pressure != suction gives W+ != W- (not the old negate). `wind_zone_mode='manual'` plus a zone carrying DIFFERENT `pressure_psf` and `suction_psf` magnitudes must NOT reproduce `wind_suction_cases`'s "negate the pressure field" shortcut -- that shortcut is exact only when the two magnitudes match, which this fixture deliberately violates (10 psf pressure, 30 psf suction) so a bug that silently falls back to the legacy path is caught by the moments actually disagreeing by more than a sign flip. """ panel = plain_wall(length_ft=40.0) zone = inplace.LateralPressureZone( x0_ft=0.0, x1_ft=40.0, pressure_psf=10.0, suction_psf=30.0, label='W1') inputs = inplace.InPlaceInputs( restraint_xs=[0.0, 20.0, 40.0], wind_zones=[zone], wind_zone_mode='manual') res = inplace.run(panel, inputs) assert res.refusal is None 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 = res.cases['W'] w_minus = cases_suction['W'] # A plain negate would give w_minus.moment == -w_plus.moment everywhere; # the 3x magnitude difference (30 vs 10 psf) must break that exactly. ratios = [abs(m) for m in w_minus.moment_lbft if abs(m) > 1e-6] assert ratios, 'suction case carries no moment at all' peak_plus = max(abs(m) for m in w_plus.moment_lbft) peak_minus = max(abs(m) for m in w_minus.moment_lbft) assert peak_minus == pytest.approx(peak_plus * 3.0, rel=0.02), ( 'suction (30 psf) should peak at 3x the pressure (10 psf) case, ' 'got {0:.1f} vs {1:.1f}'.format(peak_minus, peak_plus)) def test_manual_zone_with_pressure_only_contributes_nothing_to_suction(): """A zone with pressure_psf and no suction_psf: W- has nothing from it. PANELANALYSIS-4: "a zone with pressure only contributes nothing to W-". With every zone pressure-only, the whole deck has no suction data at all, so `build_wind_suction_cases` returns None (no envelope pass) rather than inventing a suction field off the pressure magnitude. """ panel = plain_wall(length_ft=40.0) zone = inplace.LateralPressureZone( x0_ft=0.0, x1_ft=40.0, pressure_psf=15.0, suction_psf=None, label='W1') inputs = inplace.InPlaceInputs( restraint_xs=[0.0, 20.0, 40.0], wind_zones=[zone], wind_zone_mode='manual') res = inplace.run(panel, inputs) assert res.refusal is None key_xs = res.cases['D'].xs cases_suction = inplace.build_wind_suction_cases(panel, inputs, key_xs, res.cases) assert cases_suction is None def test_manual_zone_per_zone_width_scales_the_line_load(): """A zone's OWN `width_ft` (not the panel width) sets its line load. Two otherwise-identical single-zone runs, one at the panel's width and one at double it via the zone's own `width_ft`, must differ by exactly that factor -- pins that `lateral_line_load_plf` reads the zone's width ahead of the panel-wide fallback. """ panel = plain_wall(length_ft=40.0, width_ft=8.0) narrow = inplace.LateralPressureZone( x0_ft=0.0, x1_ft=40.0, pressure_psf=20.0, width_ft=None, label='W1') wide = inplace.LateralPressureZone( x0_ft=0.0, x1_ft=40.0, pressure_psf=20.0, width_ft=16.0, label='W1') dia_narrow, _r = inplace.solve_lateral(panel, [0.0, 20.0, 40.0], [narrow], panel.width_ft, panel.fc_psi) dia_wide, _r2 = inplace.solve_lateral(panel, [0.0, 20.0, 40.0], [wide], panel.width_ft, panel.fc_psi) m_narrow = max(abs(dia_narrow.moment_at(x)) for x in [i * 40.0 / 40.0 for i in range(41)]) m_wide = max(abs(dia_wide.moment_at(x)) for x in [i * 40.0 / 40.0 for i in range(41)]) assert m_wide == pytest.approx(m_narrow * 2.0, rel=0.02) def test_legacy_zones_still_use_the_negate_shortcut(): """A deck with NO manual mode (every zone's `suction_psf` left at its dataclass default None) keeps the exact old `wind_suction_cases` path -- `build_wind_suction_cases` must dispatch to it byte for byte when `wind_zone_mode` is left at its own default 'legacy'. """ panel = strand_wall(length_ft=40.0) inputs = inplace.InPlaceInputs( restraint_xs=[0.0, 20.0, 40.0], wind_zones=[inplace.LateralPressureZone(0.0, 40.0, 20.0, 'W')]) assert inputs.wind_zone_mode == 'legacy' res = inplace.run(panel, inputs) key_xs = res.cases['D'].xs dispatched = inplace.build_wind_suction_cases(panel, inputs, key_xs, res.cases) direct = inplace.wind_suction_cases(res.cases) assert dispatched['W'].moment_lbft == direct['W'].moment_lbft assert dispatched['W'].shear_lb == direct['W'].shear_lb