Files
willtools-demo/WillTools.extension/tests/test_panelanalysis_solve.py
T
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:32:15 -05:00

224 lines
8.6 KiB
Python

# -*- coding: utf-8 -*-
"""Tests for the panelanalysis/1 SOLVE path -- the self-weight Handling model.
Loaded by path, exactly like `test_panelanalysis.py` beside it: this is host
CPython (it imports `panellift`, dataclasses, typing syntax) sitting in an
IronPython 2.7 extension, and it reaches the tool through `tools/`, which the
sibling knowledge merge in `deckcheck.py` puts the `vibe` stub in place for.
The four things the Stage 0 brief asked these to nail down:
1. the self-weight solve reactions sum to the load, the moment diagram is
real, and the per-station capacity comes from `panellift`;
2. editing the deck's supports and re-solving changes the reactions -- the
round-trip the owner wants;
3. a deck with fewer than two supports is REFUSED loudly (panellift's own
"a supported stage needs at least two supports"), surfaced as NOT JUDGED
on the page, never a traceback and never a silent OK;
4. missing prestress dashes with panellift's reason, never a zero; the
In-Place sections are explicit placeholders; zero CJK on the page.
Everything written at runtime goes under `tmp_path`; nothing touches the
working directory.
"""
from __future__ import annotations
import io
import json
import os
import re
import sys
import pytest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
TOOLS = os.path.join(ROOT, 'tools')
PANELLIFT_SRC = os.path.join(ROOT, 'panellift', 'src')
for p in (TOOLS, PANELLIFT_SRC):
if p not in sys.path:
sys.path.insert(0, p)
from vibe import knowledge # noqa: E402,F401 (registers the vibe stub)
from vibe import plantpath # noqa: E402,F401
pa = pytest.importorskip('panelanalysis')
schema = pytest.importorskip('panelanalysis_schema')
CJK = re.compile(u'[一-鿿]')
IN_PLACE_TITLES = [
'In-Place Load Case (not computed)',
'In-Place Load Combination (not computed)',
'In-Place Strength (not computed)',
'In-Place Service (not computed)',
]
# ------------------------------------------------------------------- helpers
def _solve(deck):
panel = schema.to_panel(deck)
return panel, pa.solve_handling(deck, panel)
# --------------------------------------------------------------------- solve
def test_self_weight_reactions_sum_to_the_load():
deck = schema.new_deck(support_xs=(2.0, 22.0))
panel, (analysis, refusal) = _solve(deck)
assert refusal is None
total_r = sum(l.reaction_lb for l in analysis.rig.lines)
total_load = analysis.profile.total_lb
assert total_load > 0
# panellift's consistent-load formulation makes this exact; a loose floor.
assert abs(total_r - total_load) <= max(1.0, 1e-6 * total_load)
def test_moment_diagram_is_real():
deck = schema.new_deck(support_xs=(2.0, 22.0))
_panel, (analysis, refusal) = _solve(deck)
assert refusal is None
assert len(analysis.diagram.moment_lbft) > 10
assert max(abs(m) for m in analysis.diagram.moment_lbft) > 0.0
# A simply-supported self-weight beam sags between the supports.
_x, mmax = analysis.diagram.max_positive
assert mmax > 0.0
def test_capacity_stations_come_from_panellift():
"""f_se and phi*Mn at mid-span are real numbers from panellift.capacity."""
from panellift import capacity
deck = schema.new_deck(support_xs=(2.0, 22.0))
panel, (analysis, refusal) = _solve(deck)
assert refusal is None
x = panel.length_ft / 2.0
m = analysis.diagram.moment_at(x)
v = analysis.diagram.shear_at(x)
stage = schema.handling_stage(deck, panel)
cap = capacity.capacity_at(panel, stage, x, m, v)
# A prestressed piece with developed strand at mid-span: real f_se and a
# real phi*Mn / phi*Vn, not None.
assert cap.fse_ksi is not None and cap.fse_ksi > 0
assert cap.phi_mn_lbft is not None and cap.phi_mn_lbft > 0
assert cap.phi_vn_lb is not None and cap.phi_vn_lb > 0
def test_editing_supports_changes_the_reactions():
"""The round-trip: move the supports, re-solve, the answer moves."""
# Asymmetric supports so the change is unmistakable: a symmetric panel on
# supports symmetric about its centroid gives equal reactions either way,
# which would hide the very round-trip this asserts.
a = schema.new_deck(support_xs=(2.0, 22.0)) # symmetric: half and half
b = schema.new_deck(support_xs=(2.0, 14.0)) # right support inboard
_pa, (aa, ra) = _solve(a)
_pb, (ab, rb) = _solve(b)
assert ra is None and rb is None
rx_a = [round(l.reaction_lb, 1) for l in aa.rig.lines]
rx_b = [round(l.reaction_lb, 1) for l in ab.rig.lines]
assert rx_a != rx_b
# and the sum is unchanged -- the load did not move, only how it is shared.
assert abs(sum(rx_a) - sum(rx_b)) <= 1.0
def test_three_supports_are_indeterminate_and_still_close():
deck = schema.new_deck(support_xs=(2.0, 12.0, 22.0))
_panel, (analysis, refusal) = _solve(deck)
assert refusal is None
total_r = sum(l.reaction_lb for l in analysis.rig.lines)
total_load = analysis.profile.total_lb
assert abs(total_r - total_load) <= max(1.0, 1e-6 * total_load)
# ------------------------------------------------------- the loud refusals
def test_fewer_than_two_supports_is_refused_loudly():
deck = schema.new_deck(support_xs=(5.0,))
_panel, (analysis, refusal) = _solve(deck)
assert analysis is None
assert 'at least two supports' in refusal
def test_one_support_page_says_not_judged_not_a_traceback():
deck = schema.new_deck(support_xs=(5.0,))
html = pa.build_analysis_page(deck) # must not raise
assert 'NOT JUDGED' in html
assert 'at least two supports' in html
assert 'Traceback' not in html
# The In-Place placeholders still render -- the page is whole.
for title in IN_PLACE_TITLES:
assert '>{0}<'.format(title) in html, title
def test_support_off_the_piece_is_refused():
deck = schema.new_deck(length_ft=24.0, support_xs=(2.0, 99.0))
_panel, (analysis, refusal) = _solve(deck)
assert analysis is None
assert 'outside the piece' in refusal
def test_no_prestress_capacity_dashes_with_reason_not_a_zero():
deck = schema.new_deck(strands=[])
panel = schema.to_panel(deck)
assert panel.prestress is None
html = pa.build_analysis_page(deck) # solve still runs (dunnage)
# panellift.capacity's own words for a strand-only tool with no strand --
# a dash carrying this reason, never a fabricated phi*Mn of 0.
assert 'no prestressing on this piece' in html
# The self-weight solve itself still ran (reactions do not need strand).
assert 'Support reactions' in html
# ------------------------------------------------------------- the whole page
def test_page_has_every_section_and_no_cjk():
deck = schema.new_deck(support_xs=(2.0, 22.0))
html = pa.build_analysis_page(deck)
for title in ['General / Project Information', 'Concrete Extents',
'Materials', 'Prestress / Reinforcement',
'Section Properties'] + IN_PLACE_TITLES:
assert '>{0}<'.format(title) in html, title
assert 'Support reactions' in html
assert 'Demand vs capacity' in html
m = CJK.search(html)
assert not m, 'CJK character found: {0!r}'.format(m.group() if m else '')
assert 'WP242' not in html # neutral fixture marks only
def test_cli_end_to_end_writes_only_under_tmp_path(tmp_path):
deck = schema.new_deck(support_xs=(2.0, 22.0))
deck_path = tmp_path / 'PNL101.pa.json'
out_path = tmp_path / 'out.html'
with io.open(str(deck_path), 'w', encoding='utf-8') as fh:
json.dump(deck, fh)
rc = pa.main([str(deck_path), '-o', str(out_path)])
assert rc == 0
assert out_path.exists()
html = out_path.read_text(encoding='utf-8')
assert 'Support reactions' in html
assert 'Handling' in html
# ----------------------------------------------------------------- schema
def test_unread_fields_carry_an_explicit_reason():
deck = schema.new_deck()
# in_place is the whole-block placeholder.
assert deck['in_place']['value'] is None
assert deck['in_place']['reason']
# loading.wind / combinations are the file contract's null+reason shape.
for key in ('wind', 'combinations'):
assert deck['loading'][key]['value'] is None
assert deck['loading'][key]['reason']
# structural_model restraints/bearing likewise.
for key in ('restraints', 'bearing'):
assert deck['structural_model'][key]['value'] is None
assert deck['structural_model'][key]['reason']
def test_a_foreign_schema_is_refused_by_name():
deck = schema.new_deck()
deck['schema'] = 'panellift/1'
with pytest.raises(schema.DeckError):
schema.check_schema(deck)