WillTools: precast shop-ticket QC and panel handling tools
ci / test (push) Has been cancelled

Public demo snapshot of an in-house pyRevit extension for precast concrete
shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel
handling solver (PCI Chapter 8).

- No client, job, plant or product data: knowledge layers ship as
  structural templates only.
- Ticket QC's model-read escalation targets a LAN model server endpoint
  (configurable), not a cloud provider.
- Built from the private development repo via tools/make_dist.py.
This commit is contained in:
2026-09-03 16:33:47 -05:00
commit 52d94303bc
281 changed files with 155656 additions and 0 deletions
@@ -0,0 +1,202 @@
# -*- coding: utf-8 -*-
"""The ASCE C&C wind engine wired into Panel Analysis (solve side).
`panellift.windload` is CPython (dataclasses + typing) and the Panel Analysis
form is IronPython 2.7, so the pressure is COMPUTED here, on the CPython
deck-solve side (`panelanalysis_schema` / `panelanalysis`), from the ASCE inputs
the form collects -- not in the form. These tests pin:
* the computed C&C pressure matches `windload` directly (no second opinion);
* the direct > computed > blank precedence that decides what the in-place
wind case runs on;
* the NOT-JUDGED pass-through (h>60 ft): a reason, never a fabricated pressure;
* the enclosure -> GCpi value that reaches the page;
* a wired deck actually RUNS the in-place wind case on the computed pressure,
and the page renders the pressure + GCpi with no traceback and no CJK.
Host CPython, loaded like its `test_panelanalysis*` siblings. Generic ASCE
example numbers only (the textbook low-rise wall from test_windload); no client
data.
"""
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)
pa = pytest.importorskip('panelanalysis')
schema = pytest.importorskip('panelanalysis_schema')
windload = pytest.importorskip('panellift.windload')
CJK = re.compile(u'[一-鿿]')
def _asce_deck(wind_over=None, restraints=True):
"""A mild solid wall pinned top/bottom with ASCE C&C wind inputs on it."""
deck = schema.new_deck(length_ft=30.0, width_ft=10.0, thickness_in=12.0,
fc_psi=6000.0, strands=[])
if restraints:
deck['structural_model']['restraints'] = [
{'x_ft': 0.0, 'type': 'pin'}, {'x_ft': 30.0, 'type': 'pin'}]
wind = {'asce': {
'v_ult_mph': 115, 'risk_category': 'II', 'exposure': 'C',
'enclosure': 'enclosed', 'code_version': '7-16',
'mean_roof_height_ft': 30, 'effective_wind_area_ft2': 100,
'cc_chapter': 30}}
if wind_over is not None:
wind = wind_over
deck['loading']['wind'] = wind
return deck
def _windload_governing(**kw):
"""The governing |p| windload reports directly, for the same inputs."""
res = windload.compute(
v_ult_mph=115.0, risk_category='II', exposure='C',
enclosure='enclosed', code_version='7-16',
mean_roof_height_ft=30.0, effective_wind_area_ft2=100.0, **kw)
mags = [abs(z.positive_psf) for z in res.zones] + \
[abs(z.negative_psf) for z in res.zones]
return max(mags)
# ------------------------------------------------------------- the decision
def test_computed_pressure_matches_windload_directly():
deck = _asce_deck()
info = schema.resolve_wind(deck)
assert info['source'] == 'computed'
assert info['pressure_psf'] == pytest.approx(_windload_governing(), abs=0.01)
# the figure-read uncertainty and the ASCE cite travel with it
assert info['uncertainty_psf'] > 0.0
assert info['cite']
# enclosure -> GCpi reaches the page (enclosed = 0.18)
assert info['gcpi'] == 0.18
def test_direct_entry_overrides_the_computed_pressure():
deck = _asce_deck()
deck['loading']['wind']['direct_pressure_psf'] = 45.0
info = schema.resolve_wind(deck)
assert info['source'] == 'direct'
assert info['pressure_psf'] == 45.0
# the computed values are still carried for reference
assert info['result'] is not None and not info['result'].not_judged
def test_direct_suction_alone_still_drives_the_case():
deck = _asce_deck(wind_over={'direct_suction_psf': 40.0})
info = schema.resolve_wind(deck)
assert info['source'] == 'direct'
assert info['pressure_psf'] == 40.0
def test_h_over_60_is_not_judged_never_a_fabricated_pressure():
deck = _asce_deck()
deck['loading']['wind']['asce']['mean_roof_height_ft'] = 80
info = schema.resolve_wind(deck)
assert info['source'] == 'not_judged'
assert info['pressure_psf'] is None
assert 'NOT JUDGED' in info['reason'] and '60' in info['reason']
def test_unknown_version_is_not_judged():
deck = _asce_deck()
deck['loading']['wind']['asce']['code_version'] = '7-05'
info = schema.resolve_wind(deck)
assert info['source'] == 'not_judged'
assert info['pressure_psf'] is None
def test_incomplete_asce_is_not_judged_with_what_is_missing():
deck = _asce_deck(wind_over={'asce_incomplete': 'still need Vult (mph)'})
info = schema.resolve_wind(deck)
assert info['source'] == 'not_judged'
assert 'Vult' in info['reason']
# ------------------------------------------------------------- feed to inplace
def test_wind_zones_builds_one_zone_over_the_panel_length():
deck = _asce_deck()
panel = schema.to_panel(deck)
zones, trib, reason = schema.wind_zones(deck, panel)
assert reason is None and len(zones) == 1
z = zones[0]
assert z.x0_ft == 0.0 and z.x1_ft == pytest.approx(panel.length_ft)
assert z.pressure_psf == pytest.approx(_windload_governing(), abs=0.01)
def test_explicit_given_zones_still_work_untouched():
deck = _asce_deck(wind_over={'zones': [
{'x0_ft': 0.0, 'x1_ft': 30.0, 'pressure_psf': 50.0, 'label': 'W1'}]})
panel = schema.to_panel(deck)
zones, _t, reason = schema.wind_zones(deck, panel)
assert reason is None and zones[0].pressure_psf == 50.0
# the ASCE decision is not taken for the given-pressure path
assert schema.resolve_wind(deck) is None
def test_in_place_runs_on_the_computed_wind_pressure():
deck = _asce_deck()
panel = schema.to_panel(deck)
result, reason = pa.solve_in_place(deck, panel)
assert reason is None and result is not None and not result.refusal
w = result.cases.get('W')
assert w is not None and w.built # wind case is real
assert max(abs(v) for v in w.moment_lbft) > 0.0 # it bends the wall
def test_no_restraints_still_refuses_but_wind_is_computed_for_display():
deck = _asce_deck(restraints=False)
panel = schema.to_panel(deck)
result, reason = pa.solve_in_place(deck, panel)
# in-place needs >=2 restraints; the pressure is still computable for show
assert result is None and reason
assert schema.resolve_wind(deck)['source'] == 'computed'
# ------------------------------------------------------------- the rendering
def test_page_renders_the_wind_section_pressure_and_gcpi():
deck = _asce_deck()
html = pa.build_analysis_page(deck)
assert 'In-Place Wind (ASCE C&C, computed)' in html
assert 'Computed C&C wall pressures' in html
assert 'GCpi' in html and '0.18' in html
assert 'Traceback' not in html
m = CJK.search(html)
assert not m, 'CJK: {0!r}'.format(m.group() if m else '')
def test_page_shows_not_judged_reason_and_no_pressure_for_tall_wall():
deck = _asce_deck()
deck['loading']['wind']['asce']['mean_roof_height_ft'] = 80
html = pa.build_analysis_page(deck)
assert 'NOT JUDGED' in html
assert 'Traceback' not in html
def test_cli_end_to_end_renders_wind_under_tmp_path(tmp_path):
deck = _asce_deck()
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 and out_path.exists()
html = io.open(str(out_path), encoding='utf-8').read()
assert 'In-Place Wind' in html and 'Traceback' not in html