WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The numbers behind the curves, station by station.
|
||||
|
||||
Eriksson Wall prints, after each stage's plots, a summary table of the same
|
||||
quantities at engineering stations with the governing row bolded. This is our
|
||||
version of it, and these are the properties that make it worth printing:
|
||||
|
||||
1. It carries the columns we actually compute, in the units the engineer
|
||||
states them in -- ft, kip, kip-ft, psi -- and does NOT carry the ones we
|
||||
don't. A zero in a capacity column is worse than no column at all,
|
||||
because somebody would divide by it. Six capacity columns landed
|
||||
2026-08-31 (M_cr, phi, phi-Mn, M/phi-Mn, phi-Vn, V/phi-Vn); the rest are
|
||||
still named as absent in the footnote, and a station whose capacity
|
||||
cannot be computed prints a dash and a REASON -- never a zero, never the
|
||||
neighbouring station's number.
|
||||
2. Its rows are the analysis's own stations. Thinning may drop a station
|
||||
that is only there because the grid is uniform; it may never drop an
|
||||
opening edge, a pick line, a peak, or the governing station.
|
||||
3. The governing row is marked once. Not zero times, and not twice --
|
||||
"which row is the answer" has exactly one answer.
|
||||
4. It is closed on load like every other section, and it survives the
|
||||
print overrides, because a printed report that hides its numbers is a
|
||||
report that says nothing.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import aspire_pockets, make_panel
|
||||
from panellift import checks, layout, report, statics
|
||||
from panellift.model import Blockout, Stage
|
||||
from panellift.units import parse_length
|
||||
|
||||
|
||||
def _analyse(panel, stage, rigging):
|
||||
return statics.analyse(panel, stage, rigging)
|
||||
|
||||
|
||||
def _table_of(page, after='Station table'):
|
||||
"""The one <table class="stations"> that follows this heading."""
|
||||
i = page.index(after)
|
||||
j = page.index('<table class="stations"', i)
|
||||
return page[j:page.index('</table>', j) + 8]
|
||||
|
||||
|
||||
def _rows(table):
|
||||
return re.findall(r'<tr[^>]*>(.*?)</tr>', table[table.index('<tbody>'):])
|
||||
|
||||
|
||||
def _cells(row):
|
||||
return [re.sub(r'<[^>]+>', '', c)
|
||||
for c in re.findall(r'<td[^>]*>(.*?)</td>', row)]
|
||||
|
||||
|
||||
# ------------------------------------------------ 1. the columns, converted
|
||||
|
||||
def test_the_columns_are_the_ones_we_compute_in_display_units(stripping,
|
||||
rolling):
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
table = _table_of(report.station_table(a), '<table')
|
||||
head = re.sub(r'<[^>]+>', '|', table[:table.index('</thead>')])
|
||||
for column in ('x, ft', 'V, kip', 'M, kip-ft', 'S, in', 'allow, psi',
|
||||
'/allow', 'note'):
|
||||
assert column in head, column
|
||||
assert head.count(', psi') == 3 # f_t, f_c, allow
|
||||
|
||||
|
||||
def test_the_capacity_columns_are_carried_and_the_rest_are_named_absent(
|
||||
stripping, rolling):
|
||||
"""The load-bearing honesty of the whole table, in its 2026-08-31 form.
|
||||
|
||||
Six capacity columns are computed and carried. The ones that are still
|
||||
not computed are NAMED in the footnote rather than being quietly missing,
|
||||
and the sentence that forbids zero-filling them survives.
|
||||
"""
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
html = report.station_table(a)
|
||||
table = _table_of(html, '<table')
|
||||
head = table[:table.index('</thead>')]
|
||||
for carried in ('M<sub>cr</sub>', 'φM<sub>n</sub>',
|
||||
'M/φM<sub>n</sub>', 'φV<sub>n</sub>',
|
||||
'V/φV<sub>n</sub>', 'φ'):
|
||||
assert carried in head, carried
|
||||
footnote = html[html.index('<ul class="basis">'):]
|
||||
for still_absent in ('V<sub>ci</sub>', 'V<sub>cw</sub>', 'deflection',
|
||||
'A<sub>v</sub>'):
|
||||
assert still_absent in footnote, still_absent
|
||||
assert 'left out rather than filled with zeros' in footnote
|
||||
# and the footnote does not claim the new ones are missing any more
|
||||
assert 'This revision computes none of those' not in footnote
|
||||
|
||||
|
||||
def test_every_value_is_the_engines_value_converted_once(stripping, rolling):
|
||||
"""A row's V and M are diagram.shear_lb / moment_lbft at that station
|
||||
over LB_PER_KIP -- not a re-solve, not an interpolation."""
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
points = dict((p.x, p) for p in checks.stress_profile(a))
|
||||
table = _table_of(report.station_table(a), '<table')
|
||||
xs = a.diagram.xs
|
||||
|
||||
# Column order since the 2026-09-01 reorder (f_se, added later the same
|
||||
# day, sits after f_c): x=0, M/phi-Mn=1, V/phi-Vn=2, M=3, phi-Mn=4,
|
||||
# M_cr=5, V=6, phi-Vn=7, phi=8, S=9, f_c=10, f_se=11, f_t/allow=12,
|
||||
# f_t=13, allow=14, note=15.
|
||||
for row in _rows(table):
|
||||
c = _cells(row)
|
||||
# Column 0 is now feet-inches ("8'-4 1/2\"") per the owner's
|
||||
# 2026-09-05 ruling, not a bare decimal -- parse it back rather than
|
||||
# `float()`.
|
||||
x = parse_length(c[0])
|
||||
i = min(range(len(xs)), key=lambda j: abs(xs[j] - x))
|
||||
assert abs(float(c[6].replace(',', ''))
|
||||
- a.diagram.shear_lb[i] / report.LB_PER_KIP) < 0.01
|
||||
assert abs(float(c[3].replace(',', ''))
|
||||
- a.diagram.moment_lbft[i] / report.LB_PER_KIP) < 0.1
|
||||
# and the stress column is the same profile the plot was drawn from
|
||||
gov = [r for r in _rows(table) if 'gov' in r][0]
|
||||
worst = max(points.values(), key=lambda p: p.stress_psi)
|
||||
assert abs(float(_cells(gov)[13]) - worst.stress_psi) < 0.05
|
||||
assert _cells(gov)[12] == '{0:.0%}'.format(
|
||||
worst.stress_psi / stripping.allowable_tension_psi)
|
||||
|
||||
|
||||
def test_a_negative_zero_never_reaches_a_cell(stripping, rolling):
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
table = _table_of(report.station_table(a), '<table')
|
||||
for row in _rows(table):
|
||||
for cell in _cells(row):
|
||||
assert not re.match(r'^-0(\.0*)?$', cell), cell
|
||||
|
||||
|
||||
# --------------------------------- 2. what thinning may and may not drop
|
||||
|
||||
def test_the_table_is_thinned_but_keeps_every_jump_station(stripping,
|
||||
rolling):
|
||||
"""Five pockets in a row: ten edge stations, every one of them a row,
|
||||
out of two thousand computed."""
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
assert len(a.stations) >= 2
|
||||
table = _table_of(report.station_table(a), '<table')
|
||||
rows = _rows(table)
|
||||
assert len(rows) < len(a.diagram.xs) / 10 # it really is thinned
|
||||
shown = [parse_length(_cells(r)[0]) for r in rows]
|
||||
# 0.01 ft, not 0.005 -- column 0 is now rounded to the nearest 1/8" for
|
||||
# display, so a round trip through the printed string can be off by up
|
||||
# to 1/16" (~0.0052 ft) even for the exact same station.
|
||||
for x in a.stations:
|
||||
assert any(abs(x - s) < 0.01 for s in shown), x
|
||||
assert sum('opening edge' in r for r in rows) == len(a.stations)
|
||||
|
||||
|
||||
def test_an_opening_edge_is_shown_from_both_sides(stripping, rolling):
|
||||
"""The moment is continuous across the edge and S is not. Two rows at the
|
||||
same station with different S is the section jumping -- the one thing a
|
||||
uniform grid would step over."""
|
||||
panel = make_panel(blockouts=[
|
||||
Blockout(name='B1', x0=10.0, y0=0.0, length=6.0, width=4.0)])
|
||||
a = _analyse(panel, stripping, rolling)
|
||||
rows = _rows(_table_of(report.station_table(a), '<table'))
|
||||
edges = [r for r in rows if 'opening edge' in r]
|
||||
assert len(edges) == 4 # two edges, both sides
|
||||
pair = [_cells(r) for r in edges[:2]]
|
||||
assert pair[0][0] == pair[1][0] # same station
|
||||
assert pair[0][9] != pair[1][9] # different S (col 9)
|
||||
|
||||
|
||||
def test_the_peaks_and_the_pick_lines_survive_thinning(stripping, rolling):
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
rows = _rows(_table_of(report.station_table(a), '<table'))
|
||||
text = ''.join(rows)
|
||||
for tag in ('pick line', 'max V', 'min V', 'max M', 'min M'):
|
||||
assert tag in text, tag
|
||||
shown = [parse_length(_cells(r)[0]) for r in rows]
|
||||
for line in a.rig.lines:
|
||||
assert any(abs(line.x - s) < 0.02 for s in shown), line.x
|
||||
# the ends of the piece are both there
|
||||
assert shown[0] == 0.0
|
||||
assert abs(shown[-1] - a.panel.length_ft) < 0.01
|
||||
|
||||
|
||||
def test_a_supported_stage_names_its_supports_not_pick_lines(stripping,
|
||||
rolling):
|
||||
"""Transportation on dunnage is not a lift, and the note column says the
|
||||
word the rest of that stage uses."""
|
||||
stage = Stage(name='transportation', multiplier=1.5, fc_psi=5000.0,
|
||||
supports=[6.0, 26.0])
|
||||
a = _analyse(make_panel(), stage, rolling)
|
||||
html = report.station_table(a)
|
||||
rows = _rows(_table_of(html, '<table'))
|
||||
assert sum('support' in r for r in rows) == 2
|
||||
assert 'pick line' not in ''.join(rows)
|
||||
assert 'every support' in html
|
||||
|
||||
|
||||
def test_the_rule_that_was_applied_is_printed(stripping, rolling):
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
html = report.station_table(a)
|
||||
kept = len(_rows(_table_of(html, '<table')))
|
||||
assert '{0} rows out of {1}'.format(kept, len(a.diagram.xs)) in html
|
||||
assert 'thinned' in html and 'never drop' in html
|
||||
|
||||
|
||||
def test_a_short_diagram_is_not_thinned_at_all(stripping, rolling):
|
||||
"""The stride is a cap, not a rule: nothing is dropped that there was
|
||||
room for."""
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
a.diagram.xs = a.diagram.xs[:8]
|
||||
a.diagram.shear_lb = a.diagram.shear_lb[:8]
|
||||
a.diagram.moment_lbft = a.diagram.moment_lbft[:8]
|
||||
a.stations = []
|
||||
rows, _gov = report.station_indices(a, ())
|
||||
assert [i for i, _tags in rows] == list(range(8))
|
||||
|
||||
|
||||
# --------------------------------------- 3. the governing row, marked once
|
||||
|
||||
def test_the_governing_row_is_marked_exactly_once(stripping, rolling):
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
for html in (report.station_table(a),
|
||||
report.build(a, rolling, checks.run_all(a, rolling),
|
||||
recommendation=False)):
|
||||
table = _table_of(html, '<table class="stations"')
|
||||
assert table.count('<tr class="gov">') == 1
|
||||
assert table.count('governing') == 1
|
||||
gov = [r for r in _rows(table) if 'gov' in r][0]
|
||||
assert 'governing' in gov
|
||||
|
||||
|
||||
def test_the_governing_row_is_the_worst_ratio_on_the_page(stripping, rolling):
|
||||
"""It has to be the same station the verdict, the red dot on the stress
|
||||
plot and the CRACK finding all point at."""
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
points = checks.stress_profile(a)
|
||||
worst = max(points, key=lambda p: p.stress_psi)
|
||||
table = _table_of(report.station_table(a, points), '<table')
|
||||
gov = _cells([r for r in _rows(table) if 'gov' in r][0])
|
||||
# 0.01 ft, not 0.005 -- see the note in
|
||||
# test_the_table_is_thinned_but_keeps_every_jump_station above.
|
||||
assert abs(parse_length(gov[0]) - worst.x) < 0.01
|
||||
# f_t/allow is column 12 since the 2026-09-01 reorder, f_se included (see
|
||||
# the column map at the top of this file's test_every_value_is_the_
|
||||
# engines_value...)
|
||||
ratios = [int(_cells(r)[12].rstrip('%')) for r in _rows(table)
|
||||
if _cells(r)[12].endswith('%')]
|
||||
assert int(gov[12].rstrip('%')) == max(ratios)
|
||||
|
||||
|
||||
def test_the_mark_is_bold_and_tinted_and_also_a_word(stripping, rolling):
|
||||
"""Three ways, because a print drops the tint and a monochrome print
|
||||
drops the colour. The word in the note column survives both."""
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
page = report.build(a, rolling, checks.run_all(a, rolling),
|
||||
recommendation=False)
|
||||
assert ('table.stations tr.gov td'
|
||||
'{background:var(--fillc);font-weight:700}') in page
|
||||
gov = [r for r in _rows(_table_of(page)) if 'gov' in r][0]
|
||||
assert 'governing' in gov
|
||||
|
||||
|
||||
def test_every_stage_of_a_multi_stage_report_gets_its_own_table(stripping,
|
||||
shipping,
|
||||
rolling):
|
||||
panel = make_panel(blockouts=aspire_pockets())
|
||||
cases = [(_analyse(panel, stage, rolling), rolling, [])
|
||||
for stage in (stripping, shipping)]
|
||||
for a, rig, _f in cases:
|
||||
_f.extend(checks.run_all(a, rig))
|
||||
page = report.build_many(cases, recommendation=layout.not_searched())
|
||||
assert page.count('<table class="stations"') == 2
|
||||
assert page.count('<tr class="gov">') == 2
|
||||
assert page.count('>Station table</summary>') == 2
|
||||
|
||||
|
||||
# ------------------------------------------- 4. collapsed, and it prints
|
||||
|
||||
def test_the_section_is_closed_on_load_like_every_other(stripping, rolling):
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
page = report.build(a, rolling, checks.run_all(a, rolling),
|
||||
recommendation=False)
|
||||
assert ('<details class="section stationsec"><summary>Station table'
|
||||
'</summary>') in page
|
||||
assert '<details class="section stationsec" open' not in page
|
||||
# nested inside the stage's own disclosure, not beside it
|
||||
stage_body = report.build(a, rolling, checks.run_all(a, rolling),
|
||||
recommendation=False, standalone=False)
|
||||
assert 'stationsec' in stage_body
|
||||
|
||||
|
||||
def test_the_table_survives_the_print_overrides(stripping, rolling):
|
||||
"""A closed <details> is hidden on screen by two different mechanisms in
|
||||
two generations of engine, and the print block overrides both. The table
|
||||
must be inside that, and its scroll box must open up on paper."""
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
page = report.build(a, rolling, checks.run_all(a, rolling),
|
||||
recommendation=False)
|
||||
printed = page[page.index('@media print'):]
|
||||
printed = printed[:printed.index('</style>')]
|
||||
assert 'details>*:not(summary){display:block !important}' in printed
|
||||
assert 'details::details-content' in printed
|
||||
assert '.tablewrap{overflow:visible !important}' in printed
|
||||
assert 'table.stations thead{display:table-header-group}' in printed
|
||||
assert 'details.stationsec{break-inside:auto}' in printed
|
||||
# and the table is inside a details, so those overrides reach it
|
||||
assert '<details class="section stationsec">' in page
|
||||
without = page[:page.index('<script>')]
|
||||
assert '<table class="stations"' in without # no JS needed at all
|
||||
|
||||
|
||||
# ----------------------------------------------------- 5. a real piece
|
||||
|
||||
def _sweep_jobs():
|
||||
root = Path(__file__).resolve().parents[2] / 'knowledge' / 'job'
|
||||
if not root.exists():
|
||||
return []
|
||||
return sorted(root.glob('*/sweep/jobs/*.job.json'))
|
||||
|
||||
|
||||
def _is_sandwich_job(job):
|
||||
"""Two or more concrete solids in the layup -- an insulated (or
|
||||
two-wythe) body, the makeup that used to be refused outright."""
|
||||
parts = ((job.get('source') or {}).get('solid_parts')) or []
|
||||
return sum(1 for p in parts if p.get('counted')) >= 2
|
||||
|
||||
|
||||
def test_a_real_job_renders_a_station_table():
|
||||
"""The fixtures are a published worked example; a real piece is a real
|
||||
piece. Skipped where the local job library is not checked out.
|
||||
|
||||
Renders the first real jobs that ANALYSE, and requires at least one of
|
||||
them to be an insulated/two-wythe sandwich -- the makeup that was refused
|
||||
before 2026-09-02 and now runs on the concrete-wythe composite section.
|
||||
A job that cannot be LIFT-analysed for a reason unrelated to its section
|
||||
(e.g. an erection stage whose two picked inserts collapse onto one line
|
||||
across the span -- a rigging-geometry refusal, `statics` ValueError, that
|
||||
fires on WP100-102 whether the piece is solid or sandwich) is skipped,
|
||||
not counted as a rendering failure: this test is about the station table,
|
||||
not about those pieces' rigging.
|
||||
"""
|
||||
from panellift import cli
|
||||
jobs = _sweep_jobs()
|
||||
if not jobs:
|
||||
pytest.skip('no local job library')
|
||||
import json
|
||||
rendered = 0
|
||||
sandwich_rendered = 0
|
||||
for path in jobs:
|
||||
with open(str(path), 'r', encoding='utf-8') as fh:
|
||||
job = json.load(fh)
|
||||
try:
|
||||
_panel, _rig, cases = cli.analyse_job(job)
|
||||
except (cli.JobError, ValueError):
|
||||
# Refused (an unmeasured sandwich) or un-riggable (WP100-style
|
||||
# one-line erection pick). Neither is a station-table failure.
|
||||
continue
|
||||
page = report.build_many(cases, recommendation=layout.not_searched())
|
||||
assert page.count('<table class="stations"') == len(cases)
|
||||
assert page.count('<tr class="gov">') == len(cases)
|
||||
for table in re.findall(
|
||||
r'<table class="stations".*?</table>', page, re.S):
|
||||
rows = _rows(table)
|
||||
assert 8 <= len(rows) <= 200
|
||||
assert all(len(_cells(r)) == 16 for r in rows)
|
||||
rendered += 1
|
||||
if _is_sandwich_job(job):
|
||||
sandwich_rendered += 1
|
||||
if rendered >= 3 and sandwich_rendered >= 1:
|
||||
break
|
||||
assert rendered >= 3
|
||||
# the sandwich path is the point of this work: at least one real
|
||||
# insulated/two-wythe piece must render its table, not be refused.
|
||||
assert sandwich_rendered >= 1
|
||||
Reference in New Issue
Block a user