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
+239
View File
@@ -0,0 +1,239 @@
# -*- coding: utf-8 -*-
"""The tensile limit is a job decision, and the report has to say whose.
`tension_limit_sqrt_fc` multiplies sqrt(f'c) to give the allowable tension in
psi. 5.0 is the modulus of rupture over a 1.5 safety factor -- "no
discernible cracking"; 7.5 is the rupture modulus itself, first cracking with
no margin. The two are a factor of 1.5 apart on EVERY stress verdict, which
is more than any refinement of the analysis moves, and a commercial check of
one of these panels ran at 7.5 while this defaulted to 5.0 -- that difference
alone accounted for most of the disagreement between the two.
The owner declined to settle it, 2026-08-30:
"应力极限写入 projectconfig 里面的 lifting&handling 板块里面去,让用户自己
填写。I can see it both ways."
So the engineer fills it in per job, and these tests hold the two halves of
that: the number the job states is the number the check uses, and the page
says which number that was and who chose it. A report that states 666 psi
with nothing else on it cannot be told apart from the same panel at 444 psi
by anybody reading it later, which is the whole failure being prevented.
"""
import re
import pytest
from panellift import cli, layout, report
from panellift.model import Stage
from conftest import LENGTH_FT, THICKNESS_IN, WIDTH_FT
from panellift import pci
def job(limit=None, source=None, stage_extra=None):
"""The ASPIRE panel as the exporter writes it, at whatever limit.
`limit=None` leaves the key out entirely -- a job file written before
anybody could set this, which must behave exactly as it always did.
"""
left, right = pci.optimal_two_line_supports(LENGTH_FT)
stage = {'name': 'stripping',
'multiplier': pci.stripping_multiplier('flat', 'smooth'),
'fc_psi': 4000.0}
if limit is not None:
stage['tension_limit_sqrt_fc'] = limit
if source is not None:
stage['tension_limit_source'] = source
stage.update(stage_extra or {})
return {
'schema': 'panellift/1',
'panel': {
'mark': 'ASPIRE-1',
'length_ft': LENGTH_FT,
'width_ft': WIDTH_FT,
'thickness_in': THICKNESS_IN,
'unit_weight_pcf': 150.0,
'fci_psi': 4000.0,
'fc_psi': 5000.0,
'lifters': [
{'mark': 'L1', 'family': 'TEST_LIFTER', 'x': left,
'y': 2.5, 'capacity_lb': 12000.0},
{'mark': 'L2', 'family': 'TEST_LIFTER', 'x': left,
'y': 7.5, 'capacity_lb': 12000.0},
{'mark': 'L3', 'family': 'TEST_LIFTER', 'x': right,
'y': 2.5, 'capacity_lb': 12000.0},
{'mark': 'L4', 'family': 'TEST_LIFTER', 'x': right,
'y': 7.5, 'capacity_lb': 12000.0},
],
},
'rigging': {'mode': 'rolling', 'sling_angle_deg': 90.0},
'stages': [stage],
}
def analysed(**kw):
"""(analysis, rigging, findings) for the one stage of that job."""
_panel, _rigging, cases = cli.analyse_job(job(**kw))
return cases[0]
def rendered(**kw):
"""One stage of one piece, all the way to HTML."""
return report.build_many([analysed(**kw)],
recommendation=layout.not_searched())
# ------------------------------------------------- the value reaches the sum
def test_the_job_files_limit_is_the_limit_the_check_uses():
"""The whole chain in one assertion: knowledge -> job file -> Stage ->
allowable. A field that writes a key nothing reads is the drift this
repository keeps finding, so the number has to move the arithmetic."""
five = analysed(limit=5.0)[0].stage
seven = analysed(limit=7.5)[0].stage
assert five.allowable_tension_psi == pytest.approx(5.0 * 4000.0 ** 0.5)
assert seven.allowable_tension_psi == pytest.approx(7.5 * 4000.0 ** 0.5)
# 1.5x, exactly, which is the entire argument.
assert (seven.allowable_tension_psi /
five.allowable_tension_psi) == pytest.approx(1.5)
def test_the_limit_changes_the_number_printed_on_a_real_report():
"""Not just the model object -- the rendered page. Two reports of the
same piece under the two schools must not be able to look alike."""
five = rendered(limit=5.0)
seven = rendered(limit=7.5)
assert '316 psi' in five and '474 psi' in seven
assert '474 psi' not in five and '316 psi' not in seven
def test_a_permissive_limit_can_turn_a_fail_into_a_pass():
"""Why this is the largest single source of disagreement: the verdict
itself moves, on the same panel, with the same geometry and loads."""
def verdict(limit):
# Same panel, same picks, same loads. Only f'ci is dropped to 1500
# psi, which puts the stripping stress between the two allowables:
# 5*sqrt(1500) = 194 psi and 7.5*sqrt(1500) = 290 psi.
found = [f for f in analysed(limit=limit,
stage_extra={'fc_psi': 1500.0})[2]
if f.code == 'CRACK']
return found[0].severity if found else None
assert verdict(5.0) == 'FAIL'
assert verdict(7.5) in ('OK', 'WARN')
# ----------------------------------------------------- unset is unchanged
def test_a_job_file_with_no_limit_still_gets_five():
"""Byte-identity for everything already on disk. Every job file written
before this was settable leaves the key out, and absent has to mean the
5.0 it has always meant -- not "ask", not "inherit something"."""
assert analysed()[0].stage.tension_limit_sqrt_fc == 5.0
assert Stage(name='s', multiplier=1.0, fc_psi=4000.0) \
.tension_limit_sqrt_fc == 5.0
def test_absent_and_explicit_five_compute_identically():
"""The default is the same code path as a stated 5.0, down to the
findings text -- apart from the one word saying who chose it."""
absent, stated = analysed(), analysed(limit=5.0)
a = [(f.severity, f.code) for f in absent[2]]
b = [(f.severity, f.code) for f in stated[2]]
assert a == b
assert (absent[0].stage.allowable_tension_psi ==
stated[0].stage.allowable_tension_psi)
# --------------------------------------------------------- who chose it
def test_provenance_is_derived_when_the_file_does_not_say():
"""A stage that states the limit is a stage somebody set; one that does
not is running panellift's own number. Neither needs a second key."""
assert analysed()[0].stage.tension_limit_source == 'default'
assert analysed(limit=7.5)[0].stage.tension_limit_source == 'job'
def test_the_exporter_can_say_a_stage_overrode_the_job():
"""The one thing this side cannot work out for itself: a handling.stages
entry that beat the job-wide Project Config field. Without it, the
engineer who typed 5.0 into that field and got a 7.5 report has nothing
on the page that explains the difference."""
stage = analysed(limit=7.5, source='stage')[0].stage
assert stage.tension_limit_source == 'stage'
assert 'stage' in stage.tension_basis
def test_an_unknown_provenance_word_is_refused_not_displayed():
"""A label that can be quietly wrong is worse than no label: 'jobb' read
as 'job' would print a confident sentence about the wrong layer."""
with pytest.raises(cli.JobError) as exc:
cli.analyse_job(job(limit=7.5, source='jobb'))
assert 'tension_limit_source' in str(exc.value)
def test_the_basis_string_is_ascii():
"""Findings are printed to whatever console launched the run. A radical
sign there is a crash on a cp1252 code page, not a nicer report."""
stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0)
stage.tension_basis.encode('ascii')
assert stage.tension_limit_text == "5*sqrt(f'c)"
assert Stage(name='s', multiplier=1.0, fc_psi=4000.0,
tension_limit_sqrt_fc=7.5).tension_limit_text == \
"7.5*sqrt(f'c)"
# ------------------------------------------------ the report cannot be mute
def test_every_finding_that_states_an_allowable_states_the_limit():
"""CRACK is the check the limit decides, and its line has to carry the
coefficient beside the psi -- 444 and 666 on the same concrete are two
doctrines, and the psi alone does not say which."""
found = [f for f in analysed(limit=7.5)[2] if f.code == 'CRACK']
assert found
assert "7.5*sqrt(f'c), job" in found[0].message
def test_the_stress_plot_title_names_the_limit_it_drew():
"""It used to say 5*sqrt(f'c) whatever the stage ran at -- the one place
a 7.5 job was labelled as a 5.0 one."""
svg = report.stress_plot(analysed(limit=7.5)[0])
assert '7.5' in svg
assert 'against 5' not in svg
def test_the_basis_list_says_where_the_coefficient_came_from():
page = rendered(limit=7.5)
assert 'as the job file states it' in page
# notate() dresses "7.5*sqrt(f'c)" up as a real radical over a real
# f-prime-sub-c, not the ASCII the underlying strings still carry.
assert '7.5' + report.RADICAL_FC in page
def test_the_default_is_named_as_a_default_on_the_page():
"""The report must not present panellift's own fallback as a decision
somebody made."""
page = rendered()
assert "panellift's own default" in page or \
"panellift's own default" in page
def test_the_stage_table_carries_the_limit_under_the_allowable():
"""Two stages of one piece may run at different limits, so it is per row
rather than a footnote."""
page = rendered(limit=7.5)
assert 'class="limbasis"' in page
# notate() renders "7.5*sqrt(f'c)" as a real radical, so the cell now
# carries nested tags -- match to the closing </span> of the limbasis
# element itself, not to the first '<' inside it.
cell = re.search(r'<span class="limbasis">(.*?)</span></td>', page)
assert cell and '7.5' in cell.group(1) and 'job' in cell.group(1)
assert report.RADICAL_FC in cell.group(1)
def test_the_stage_box_shows_the_limit_beside_the_ratio():
"""The boxes are what a reader takes in and moves on from."""
page = rendered(limit=7.5)
box = page[page.index('<div class="stagebox-detail">'):][:260]
# notate() renders "7.5*sqrt(f'c), job" as a real radical + f-prime-sub-c.
assert '7.5' + report.RADICAL_FC + ', job' in box