WillTools 2026-09-09 -- clean snapshot of the development repo for review
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:
2026-09-09 08:35:15 -05:00
commit 25592fa6c2
406 changed files with 223435 additions and 0 deletions
+761
View File
@@ -0,0 +1,761 @@
# -*- coding: utf-8 -*-
"""Panel Analysis Reinforcement page -- the real strand/rebar pre-fill.
WHY THIS EXISTS: the owner's complaint, verbatim, 2026-09-03: "reinforcement
也没正确识别 rebar 跟 strands" (the Reinforcement page pre-fills almost
nothing). Two separate reads, kept in two functions because they come from
two different sources and fail independently:
* `strand_prefill` turns Panel Lift's own strand read (`liftexport.
read_prestress`'s `panel.prestress` shape -- diameter, loss, and a list
of `{area_in2, initial_pull_lb, y, z_in}` per strand) into the form's
`ps_*` fields, in the exact grammar `panelanalysisform.parse_strand_rows`
reads back (`'count @ depth_in [@ pull_pct]'` per line).
* `rebar_prefill` classifies an assembly's members the same way
`_assembly_reinforcement` in the PanelAnalysis button script does
(`K.is_strand`, the WWF prefix, `K.reinforcing_verdict`), but for the
REBAR half it stops reporting member MARKS (useless -- a comma list of
piece marks does not say how much steel is in the panel) and instead
reports a real `(bar size, type name)` summary with a total area.
`read_reinforcement_prefill` is the merged entry point the button would call
-- see the bottom of this docstring for the wiring `_reads_for` still needs.
WHERE z_in and depth_in AGREE, so no conversion runs here
-----------------------------------------------------------
`ps_rows`' `depth_in` is documented as "how deep from the bottom"
(`panelanalysisform.py`, the `ps_rows` `_F(...)` entry, and again in
`parse_strand_rows`'s docstring: "e.g. '4 @ 2 @ 75' is 4 strands, 2 in. from
the bottom"). `liftexport.read_prestress` measures its own `z_in` the same
way -- `centre[T] - lo[T]`, with the comment directly above it: "Up from the
form face. The form face is the low end of the thickness axis: a panel is
cast face down on the bed." (`liftexport.py` lines 1601-1604). Both are
"distance up from the low end of the thickness axis" -- the same axis, the
same zero, the same direction -- and `panelanalysisform._prestress_from`
proves it operationally: when it turns a typed `ps_rows` line back into a
strand dict for the solve, it writes `'z_in': row['depth_in']` directly, with
no thickness arithmetic in between (`panelanalysisform.py` line 1849). So
`strand_prefill` below does the same thing in reverse -- `depth_in =
z_in` -- and `thickness_in` is accepted (for parity with the caller, which
already has it on hand) but never used to flip or offset anything. If a
future family of parts ever measures `z_in` from the OTHER face, that is the
place to add `thickness_in - z_in`; nothing observed on this job needs it.
No Revit import at module scope. `rebar_prefill` and `read_reinforcement_
prefill` reach for `vibe.knowledge` / `vibe.ticketqc` only inside the
function that needs them, exactly like `_assembly_reinforcement` in the
button script -- so this module imports cleanly on host CPython (`tests/
conftest.py`'s trick) even though `ticketqc` itself cannot.
"""
import re
# Same default the module docstring above cites: no plant maps
# `panelanalysis.bar_size_pattern`, so the last two digits of the TYPE name
# are read as the bar size -- 'S04' -> #4, 'MC_17-04' -> #4, 'MC_T2-05' -> #5.
DEFAULT_BAR_SIZE_PATTERN = r'(\d{2})$'
# Standard ASTM bar areas, in^2. A size this table does not know contributes
# no area to `mild_as_in2` -- same discipline as `panelanalysisform.
# strand_area_in2`: an unknown size is never guessed at, only named.
_BAR_AREA_IN2 = {3: 0.11, 4: 0.20, 5: 0.31, 6: 0.44, 7: 0.60, 8: 0.79}
_DEFAULT_FPU_KSI = 270.0
def _mode(values):
"""The most common value, ties broken by first appearance. None if empty.
Plain dict-counting rather than `collections.Counter` -- this file has to
import on IronPython 2.7 inside the button as well as host CPython, and
Counter's ordering guarantees are not worth relying on for the tie-break.
"""
values = [v for v in values if v is not None]
if not values:
return None
counts = {}
order = []
for v in values:
if v not in counts:
counts[v] = 0
order.append(v)
counts[v] += 1
best = order[0]
best_n = counts[best]
for v in order[1:]:
if counts[v] > best_n:
best = v
best_n = counts[v]
return best
def _fmt_dia(dia):
try:
return '{0:g}'.format(float(dia))
except (TypeError, ValueError):
return None
def strand_prefill(prestress, thickness_in, fpu_ksi=None):
"""The `ps_*` fields off Panel Lift's own strand read, or {}.
`prestress` is `panel.prestress` from `liftexport.export()` / the return
of `liftexport.read_prestress`: `{'diameter_in', 'loss_pct', 'strands':
[{'area_in2', 'initial_pull_lb', 'y', 'z_in', ...}, ...]}`. None, {}, or a
prestress dict with no strands all mean "nothing to prefill" -> {}, the
same "no prestress" the form itself treats as valid (all `ps_*` blank).
`thickness_in` is accepted for parity with the caller (see the module
docstring for why it is not used to convert `z_in`) and may be None.
`ps_pull_lb` is the MODAL `initial_pull_lb` across all strands -- the
pull most of them share -- so it stands in as the form's one default pull
while `ps_rows` calls out only the groups that differ from it.
`ps_rows` is one line per distinct depth, DEPTHS ROUNDED TO THE NEAREST
1/8 in. before grouping (real strand beds are not measured to the
thousandth, and without rounding, floating-point noise in the geometry
read would split one physical row of strands into several). A group's
line carries ' @ {pull_pct:.0f}' only when that group's own pull differs
from the modal pull -- matching, so that the common case (every strand
pulled the same) reads as plain 'count @ depth' lines with no percentage
noise, exactly the shape a hand-typed strand schedule uses.
`strand_rows` is the STRUCTURED alternative to `ps_rows` -- one dict per
depth group, `{'count', 'depth_in', 'dia_in', 'pull_lb'}` -- for
`panelanalysisform.prefill` to seed the `strand_row` table directly, so a
real per-row diameter/pull rides in from the start instead of a text
line that gets re-parsed. Same depth grouping (rounded to 1/8 in.) as
`ps_rows`; `dia_in` / `pull_lb` are the SAME value on every row -- Panel
Lift's own `read_prestress` stamps one diameter and one `it_lb` pull onto
every strand it finds (it refuses outright when the design summary lists
more than one pulled group), so there is only ever one design value of
each to put on a row, never a per-row measurement.
`strand_x_in` is the flat per-strand horizontal position list
`pa_sketch_reinf._explicit_strand_x` reads (one entry per strand, in
`strand_rows` order -- depth-group order, then the order `read_prestress`
already sorted its strands in, ascending `y`). Each value is that
strand's `y` (from `read_prestress`'s own `to_y`, feet from the WIDTH
axis' own low end in the piece's as-drawn/erected frame -- the identical
convention `job['panel']['lifters']`' own `x`/`y` and this piece's `y`
already use) converted to inches. That is NOT necessarily "from the rail
`strand_rail_location` names" -- which rail the dimension chain is really
drawn from is separate plant knowledge this function has no access to
(it takes no `know`) -- so the position is reported honestly as
model-frame-relative, and only emitted at all when every strand in
`strand_rows` produced one (a partial list would silently misalign
against the rows it is supposed to match).
"""
if not prestress:
return {}
strands = prestress.get('strands') or []
if not strands:
return {}
out = {'ps_count': len(strands)}
dia_text = _fmt_dia(prestress.get('diameter_in'))
if dia_text is not None:
out['ps_dia_in'] = dia_text
loss = prestress.get('loss_pct')
if loss is not None:
out['ps_loss_pct'] = loss
fpu = fpu_ksi
if fpu is None:
fpu = prestress.get('fpu_ksi')
if fpu is None:
fpu = _DEFAULT_FPU_KSI
pulls = [s.get('initial_pull_lb') for s in strands]
modal_pull = _mode(pulls)
if modal_pull is not None:
out['ps_pull_lb'] = modal_pull
groups = {}
for s in strands:
z = s.get('z_in')
if z is None:
continue
depth = round(z * 8.0) / 8.0
groups.setdefault(depth, []).append(s)
lines = []
rows = []
xs = []
for depth in sorted(groups.keys()):
members = groups[depth]
line = '{0} @ {1:g}'.format(len(members), depth)
group_pull = _mode([m.get('initial_pull_lb') for m in members])
if (group_pull is not None and modal_pull is not None
and abs(group_pull - modal_pull) > 1e-6):
area = _mode([m.get('area_in2') for m in members])
if area:
pull_pct = group_pull / (area * fpu * 1000.0) * 100.0
line += ' @ {0:.0f}'.format(pull_pct)
lines.append(line)
rows.append({'count': len(members), 'depth_in': depth,
'dia_in': dia_text, 'pull_lb': modal_pull})
for m in sorted(members, key=lambda s: s.get('y') or 0.0):
y_ft = m.get('y')
if y_ft is not None:
xs.append(round(y_ft * 12.0, 3))
if lines:
out['ps_rows'] = '\n'.join(lines)
if rows:
out['strand_rows'] = rows
out['strand_rows_basis'] = (
'{0} strand row(s) grouped by depth (rounded to the nearest '
'1/8 in., matching ps_rows) off the assembly\'s strand members: '
'depth is z_in, up from the FORM FACE -- the low end of the '
'thickness axis, since a panel is cast face down on the bed; '
'diameter and pull are the design spec\'s one group value '
'(it_lb), the same value every strand on this piece already '
'carries.'.format(len(rows)))
strand_count = sum(r['count'] for r in rows)
if xs and len(xs) == strand_count:
out['strand_x_in'] = xs
out['strand_x_in_basis'] = (
'per-strand horizontal position, in inches, off '
'read_prestress\'s own to_y (feet from the width axis\' low '
'end in the as-drawn/erected frame -- the same convention '
"job['panel']['lifters'] x/y already use), one entry per "
'strand in strand_rows order. This is NOT necessarily '
'measured from the rail strand_rail_location names -- which '
'rail the dimension chain is drawn from on the drawing is '
'separate plant knowledge this read has no access to.')
return out
def _type_name_of(box):
"""The member's TYPE name, or None. The box itself first, the element
second.
`ticketqc.member_boxes` does not put a `type` key on the box at all --
confirmed by reading its source (`ticketqc.py`, `member_boxes`: the dict
it appends carries `id, family, category, mark, lo, hi, element`, no
`type`) -- and a probe run against a real assembly came back with every
box's `type` key present but `None`. So the type name is read off
`box['element']` instead: for a `FamilyInstance`, `.Name` (the base
`Element.Name`, not `.Symbol.Name`) IS the type name, a well-known Revit
API quirk. Wrapped in `try` because `box['element']` may be missing
entirely (a hand-built test box) or the API call may fail for a reason
that has nothing to do with this function.
"""
t = box.get('type')
if t:
return str(t)
element = box.get('element')
if element is not None:
try:
name = element.Name
except Exception:
name = None
if name:
return str(name)
return None
def _bar_size_from_type(type_name, pattern):
if not type_name:
return None
try:
compiled = re.compile(pattern)
except Exception:
compiled = re.compile(DEFAULT_BAR_SIZE_PATTERN)
m = compiled.search(type_name)
if not m:
return None
try:
return int(m.group(1))
except (IndexError, ValueError, TypeError):
return None
def rebar_prefill(boxes, know):
"""`{'mild_as', 'mild_as_in2', 'mesh', 'reinf_basis'}` off assembly boxes.
`boxes` is the shape `ticketqc.member_boxes` returns (a list of `{family,
category, mark, lo, hi, element}` dicts, `type` optional). Classification
matches `_assembly_reinforcement` in the PanelAnalysis button script
exactly: `K.is_strand` first (strands are `strand_prefill`'s job, not
this one's), then the `families.wwf_prefix` test for mesh, then
`K.reinforcing_verdict(know, family)[0] is True` for rebar -- `None`
(cannot judge) and `False` are both left OUT of the count, the same
"never invent a verdict" rule `reinforcing_verdict` itself documents.
Bars are summarised by (bar size, type name), not by member mark: a
52-bar wall is 52 marks and one useful sentence, not 52 useless ones.
Size comes from the plant's `panelanalysis.bar_size_pattern` (default
`r'(\\d{2})$'`) applied to the TYPE name (see `_type_name_of`). A bar
whose type name does not match the pattern (or has no type name at all)
is still counted and shown -- as `'{n} x ? ({type})'` -- and named again
in `reinf_basis`, rather than silently dropped, because a bar this
function cannot size is still a bar somebody has to notice is missing an
area.
`mild_as_in2` totals only the bars whose size WAS parsed, using the
standard ASTM areas in `_BAR_AREA_IN2` -- an unparsed bar contributes no
area, matching `panelanalysisform.strand_area_in2`'s refusal to guess.
Returns {} when the boxes carry neither mesh nor rebar (an all-hardware
assembly, or none supplied).
"""
if not boxes:
return {}
from vibe import knowledge as K
try:
wwf_prefix = str(K.get(know, 'families.wwf_prefix', 'WWF') or 'WWF')
except Exception:
wwf_prefix = 'WWF'
wwf_prefix = wwf_prefix.upper()
try:
pattern = K.get(know, 'panelanalysis.bar_size_pattern',
DEFAULT_BAR_SIZE_PATTERN)
except Exception:
pattern = DEFAULT_BAR_SIZE_PATTERN
if not pattern:
pattern = DEFAULT_BAR_SIZE_PATTERN
mesh_names = []
rebar_groups = {} # (size or None, type_name or None) -> count
# Every family whose verdict came back other than True -- False
# ("confirmed not a bar", the ordinary case for connection hardware) and
# None ("cannot judge", the ambiguous case) are NOT told apart here, on
# purpose: `reinforcing_verdict`'s own "list" mode returns a confident
# False for a family nobody has EXPLICITLY ruled out, same as one that
# really is just hardware (2026-09-07 finding on WPA655 -- the bare
# `S_(SCHEDULED)` family, still an open plant-knowledge question, comes
# back False and reads exactly like `DB_PATCH` would). So this function
# cannot tell "safely excluded" from "quietly dropped a real bar group"
# any better than the caller could -- it just stops hiding the count.
excluded = {} # family -> count
for b in boxes:
family = b.get('family') or ''
try:
if K.is_strand(know, family):
continue
except Exception:
pass
if family.upper().startswith(wwf_prefix):
if family not in mesh_names:
mesh_names.append(family)
continue
try:
verdict = K.reinforcing_verdict(know, family)[0]
except Exception:
verdict = None
if verdict is not True:
excluded[family] = excluded.get(family, 0) + 1
continue
type_name = _type_name_of(b)
size = _bar_size_from_type(type_name, pattern)
key = (size, type_name)
rebar_groups[key] = rebar_groups.get(key, 0) + 1
if not mesh_names and not rebar_groups:
return {}
out = {}
if mesh_names:
out['mesh'] = ', '.join(mesh_names)
if rebar_groups:
def _sort_key(item):
(size, type_name), _count = item
return (size if size is not None else float('inf'),
type_name or '')
parts = []
basis = []
total_area = 0.0
for (size, type_name), count in sorted(rebar_groups.items(),
key=_sort_key):
label = type_name if type_name else 'unknown type'
if size is None:
parts.append('{0} x ? ({1})'.format(count, label))
basis.append(
"{0} x {1} -- no bar size parsed from the type name "
"(panelanalysis.bar_size_pattern {2!r})"
.format(count, label, pattern))
else:
parts.append('{0} x #{1} ({2})'.format(count, size, label))
area = _BAR_AREA_IN2.get(size)
if area is not None:
total_area += area * count
out['mild_as'] = ', '.join(parts)
out['mild_as_in2'] = '{0:.2f}'.format(total_area)
if excluded:
n_members = sum(excluded.values())
by_family = ', '.join(
'{0} {1}'.format(excluded[f], f) for f in sorted(excluded))
basis.append(
'{0} other member(s) across {1} famil{2} ({3}) did not '
'verdict as reinforcing under the current knowledge -- '
'False here can mean "confirmed hardware" or "an '
'unresolved plant-knowledge question" equally, see '
'drawings.reinforcing_family_patterns / '
'_family_overrides for which.'
.format(n_members, len(excluded),
'y' if len(excluded) == 1 else 'ies', by_family))
if basis:
out['reinf_basis'] = basis
return out
# Bars closer together than this (rounded) are the same depth group -- the
# same discipline `strand_prefill` uses on strand depth (1/8 in.), loosened
# to 1/4 in. because rebar is not measured to strand tolerance.
_MILD_DEPTH_GROUP_IN = 0.25
def _member_axes(el, asm, know):
"""(to_x, to_y, lo, hi, L, W, T, basis) for `el`'s own panel frame, or
None on any failure.
The SAME (length, width, thickness) axis assignment and up-end flip
`liftexport.export` uses for everything else Panel Lift measures --
`liftexport._axes` on the panel's own `ticketqc.concrete_extents` (built
off the SAME `ticketqc._panel_frame` the strand/rebar boxes are already
read in), then `liftexport.erected_orientation` for which end reads as
x=0. Reusing both rather than re-deriving them is the point: a mild bar's
`from_ft`/`to_ft` has to land in the identical as-drawn frame
`job['panel']['lifters']`' x/y and `strand_x_in` already use, or the
three would each describe "the same piece" in a different, disagreeing
coordinate system.
When `asm` is None, or the origin rule cannot say which end is up, the
axes still resolve (thickness/length/width are pure geometry) but x/y
keep the model's own order -- `basis` names that explicitly rather than
silently asserting an orientation nobody established.
"""
try:
from vibe import ticketqc as tq
from vibe import liftexport as LX
except Exception:
return None
try:
frame = tq._panel_frame(el)
extents = tq.concrete_extents(el, frame)
except Exception:
return None
if frame is None or extents is None:
return None
lo = [v * 12.0 for v in extents[0]]
hi = [v * 12.0 for v in extents[1]]
try:
L, W, T = LX._axes(lo, hi)
except Exception:
return None
length_ft = (hi[L] - lo[L]) / 12.0
width_ft = (hi[W] - lo[W]) / 12.0
x_ascends, y_ascends = True, True
basis = ('the model\'s own axis order -- no assembly was given, so the '
'erected up-end rule could not run')
if asm is not None:
try:
vertical_axis, up_is_max, shares = LX.erected_orientation(
asm, el, frame, (L, W, T), know, (length_ft, width_ft))
except Exception:
vertical_axis, up_is_max, shares = None, None, {}
if vertical_axis is not None:
if vertical_axis == 'length':
x_ascends, y_ascends = up_is_max, True
else:
x_ascends, y_ascends = True, up_is_max
basis = ('the erected orientation (basis: {0})'
.format(shares.get('basis') or 'unknown'))
else:
basis = ('the model\'s own axis order -- the erected up-end '
'rule could not tell which end of this piece is up')
def to_x(value_in):
along = (value_in - lo[L]) / 12.0
return along if x_ascends else length_ft - along
def to_y(value_in):
across = (value_in - lo[W]) / 12.0
return across if y_ascends else width_ft - across
return {'to_x': to_x, 'to_y': to_y, 'lo': lo, 'hi': hi,
'L': L, 'W': W, 'T': T, 'basis': basis}
def _median_gap(values):
values = sorted(values)
gaps = sorted(values[i + 1] - values[i]
for i in range(len(values) - 1))
if not gaps:
return None
mid = len(gaps) // 2
if len(gaps) % 2:
return gaps[mid]
return (gaps[mid - 1] + gaps[mid]) / 2.0
def cluster_split(members, spread_key, gap_ratio=5.0, min_gap_in=6.0):
"""[[member, ...], ...] -- `members` (each a dict carrying `spread_key`)
split into CLUSTERS along that key wherever consecutive members are far
enough apart to be a different physical feature, not one spaced-out
run. PUBLIC (module level, not nested in `mild_bar_rows_prefill`) so it
is directly host-testable with synthetic data -- the function it serves
needs a live `el`/`asm` (through `_member_axes`) and cannot run at all
on plain CPython, which is exactly why this fix needed a test of its
own reachable a different way.
2026-09-05, WPA655 finding: a (size, depth, direction) GROUP can
legitimately hold bars from two unrelated locations (e.g. two separate
openings' own diagonal edge bars) that happen to share a size/depth/
direction -- grouping them into ONE row reported a `spacing_in` (the
median gap across ALL members) fooled by exactly this shape: two tight
within-location pairs far apart read as "spacing 3in" (the median of
[3in, 3in, ~37ft]) while `from_ft`/`to_ft` correctly spanned the whole
panel -- an internally INCONSISTENT row (a reader sees '4 bars, 3in
apart, 0 to 37 ft' and the arithmetic does not close: 4 bars at 3in
apart span 9in, not 37ft).
Sorts by `spread_key` and starts a new cluster whenever a gap is more
than `gap_ratio` times the SMALLEST positive gap seen in the whole run
-- a within-cluster gap sits close to that minimum for a genuinely
evenly-spaced pattern; a gap to the next physical feature does not.
`min_gap_in` floors the 'smallest gap' so two bars only an inch or two
apart (an ordinary production tolerance) never reads as its own tiny
cluster unit that then flags every larger, still-legitimate spacing as
a break. Fewer than 3 members is returned as one cluster
unconditionally -- there is no spacing pattern that small to protect
anyway.
"""
if len(members) < 3:
return [members]
ordered = sorted(members, key=lambda m: m[spread_key])
gaps = [ordered[i + 1][spread_key] - ordered[i][spread_key]
for i in range(len(ordered) - 1)]
positive = [g for g in gaps if g > 1e-6]
if not positive:
return [ordered]
threshold = max(min(positive), min_gap_in) * gap_ratio
clusters = [[ordered[0]]]
for i, gap in enumerate(gaps):
if gap > threshold:
clusters.append([])
clusters[-1].append(ordered[i + 1])
return clusters
def mild_bar_rows_prefill(el, asm, know, boxes):
"""`{'mild_bar_rows', 'mild_bar_rows_basis'}` off assembly rebar boxes,
or {} -- the STRUCTURED alternative to `rebar_prefill`'s `mild_as`
summary text, for `panelanalysisform.prefill` to seed the `mild_bar`
table directly (`[{'size', 'count', 'depth_in', 'direction', 'from_ft',
'to_ft', 'spacing_in'}, ...]`).
Classification is identical to `rebar_prefill` (strand/mesh/rebar,
`K.is_strand` -> `families.wwf_prefix` -> `K.reinforcing_verdict`), so
the two functions always agree on WHICH boxes are rebar; this one just
also places them. `boxes` needs `el`/`asm` (through `_member_axes`) for
the length/width/thickness axes and the erected x/y flip -- `rebar_
prefill` never needed either, so it keeps its own simpler signature.
One row per (bar size, depth rounded to the nearest 1/4 in., direction)
group. `direction` is 'Longitudinal' when a bar's own box runs longer
along the LENGTH axis than the WIDTH axis, 'Transverse' otherwise --
ties go to Longitudinal. `depth_in` is the group's boxes' average box
centre on the THICKNESS axis, measured from the FORM FACE -- same
convention `strand_prefill`'s `z_in` uses (the low end of the thickness
axis; a panel is cast face down on the bed). `from_ft`/`to_ft` are
always the group's extreme LENGTH-axis positions (`to_x` at each
member's own two ends, min and max across the group) -- for a
longitudinal group that is each bar's own run; for a transverse group
(each bar short along the length, spread out along it) it is the span
from the first bar's position to the last. `spacing_in` is the median
gap between members along whichever axis they are actually spread over
-- WIDTH for a longitudinal group (parallel bars set apart across the
panel), LENGTH for a transverse group -- and is left `None` for a group
of fewer than 3 members, where "the gap" is not a well-formed spacing.
A box missing `lo`/`hi` (a hand-built test box, or a geometry solve that
failed upstream) is skipped, not guessed at. Returns {} when there is
nothing to place (`boxes` empty, no box classifies as rebar, or the
panel's own axes could not be solved) or on any exception -- never
raises.
"""
if not boxes:
return {}
try:
from vibe import knowledge as K
except Exception:
return {}
axes = _member_axes(el, asm, know)
if axes is None:
return {}
to_x = axes['to_x']
lo, L, W, T = axes['lo'], axes['L'], axes['W'], axes['T']
try:
wwf_prefix = str(K.get(know, 'families.wwf_prefix', 'WWF') or 'WWF')
except Exception:
wwf_prefix = 'WWF'
wwf_prefix = wwf_prefix.upper()
try:
pattern = K.get(know, 'panelanalysis.bar_size_pattern',
DEFAULT_BAR_SIZE_PATTERN)
except Exception:
pattern = DEFAULT_BAR_SIZE_PATTERN
if not pattern:
pattern = DEFAULT_BAR_SIZE_PATTERN
groups = {} # (size, depth_key, direction) -> [member dict, ...]
# Same tracking, and the same caveat, as `rebar_prefill`'s `excluded`:
# a False verdict here can mean "confirmed hardware" or "an unresolved
# plant-knowledge question" (2026-09-07, WPA655: the bare `S_(SCHEDULED)`
# family -- 42 real mild-bar members, #3/#4/#5 -- verdicted False and
# vanished from this table with no trace, because nothing distinguished
# it from an ordinary connection embed). Never silently drop the count.
excluded = {} # family -> count
for b in boxes:
family = b.get('family') or ''
try:
if K.is_strand(know, family):
continue
except Exception:
pass
if family.upper().startswith(wwf_prefix):
continue
try:
verdict = K.reinforcing_verdict(know, family)[0]
except Exception:
verdict = None
if verdict is not True:
excluded[family] = excluded.get(family, 0) + 1
continue
blo, bhi = b.get('lo'), b.get('hi')
if blo is None or bhi is None:
continue
type_name = _type_name_of(b)
size = _bar_size_from_type(type_name, pattern)
span_L = bhi[L] - blo[L]
span_W = bhi[W] - blo[W]
direction = 'Longitudinal' if span_L >= span_W else 'Transverse'
depth_in = ((blo[T] + bhi[T]) / 2.0) - lo[T]
depth_key = round(depth_in / _MILD_DEPTH_GROUP_IN) * _MILD_DEPTH_GROUP_IN
x0, x1 = sorted((to_x(blo[L]), to_x(bhi[L])))
centre_L_in = (blo[L] + bhi[L]) / 2.0
centre_W_in = (blo[W] + bhi[W]) / 2.0
groups.setdefault((size, depth_key, direction), []).append({
'depth_in': depth_in, 'x0': x0, 'x1': x1,
'along_length_in': centre_L_in,
'along_width_in': centre_W_in,
})
if not groups:
return {}
def _sort_key(item):
(size, depth_key, direction), _members = item
return (size if size is not None else float('inf'), depth_key,
direction)
rows = []
unsized = 0
n_split = 0
for (size, depth_key, direction), members in sorted(groups.items(),
key=_sort_key):
spread_key = ('along_width_in' if direction == 'Longitudinal'
else 'along_length_in')
clusters = cluster_split(members, spread_key)
if len(clusters) > 1:
n_split += 1
for cluster in clusters:
count = len(cluster)
if size is None:
unsized += count
depth_avg = sum(m['depth_in'] for m in cluster) / float(count)
from_ft = min(m['x0'] for m in cluster)
to_ft = max(m['x1'] for m in cluster)
spacing_in = None
if count >= 3:
spacing_in = _median_gap([m[spread_key] for m in cluster])
rows.append({
'size': ('#{0}'.format(size) if size is not None else None),
'count': count, 'depth_in': round(depth_avg, 3),
'direction': direction, 'from_ft': round(from_ft, 3),
'to_ft': round(to_ft, 3),
'spacing_in': (round(spacing_in, 3)
if spacing_in is not None else None),
})
basis = (
'{0} rebar group(s) read off the assembly members, grouped by (bar '
'size, depth rounded to the nearest 1/4 in., direction), each '
'further split into separate rows wherever the members are not one '
'evenly-spaced run (see the 2026-09-05 clustering note). Size from '
'the type name (panelanalysis.bar_size_pattern {1!r}); depth is the '
'box centre on the thickness axis, up from the FORM FACE (the low '
'end of the thickness axis -- a panel is cast face down on the '
'bed, the same convention strand depth uses); direction is '
"Longitudinal when a bar's own box runs longer along the panel's "
'length than its width, Transverse otherwise; from/to are the '
"row's extreme positions along the length ({2}); spacing is the "
'median gap across the perpendicular axis for a row of 3 or more '
'bars, left blank otherwise.'.format(len(rows), pattern,
axes['basis']))
if n_split:
basis += (' {0} bar-size/depth/direction group(s) were not one '
'evenly-spaced run and were split into separate rows so '
'each row\'s spacing matches its own extent.'
.format(n_split))
if unsized:
basis += (' {0} bar(s) carried with no size parsed from the type '
'name.'.format(unsized))
if excluded:
n_members = sum(excluded.values())
by_family = ', '.join(
'{0} {1}'.format(excluded[f], f) for f in sorted(excluded))
basis += (
' {0} other member(s) across {1} famil{2} ({3}) did not '
'verdict as reinforcing under the current knowledge and are NOT '
'in this table -- False there can mean "confirmed hardware" or '
'"an unresolved plant-knowledge question" equally, see '
'drawings.reinforcing_family_patterns / _family_overrides for '
'which.'.format(n_members, len(excluded),
'y' if len(excluded) == 1 else 'ies', by_family))
return {'mild_bar_rows': rows, 'mild_bar_rows_basis': basis}
def read_reinforcement_prefill(el, asm, know, prestress=None, fpu_ksi=None):
"""Both halves merged: strands off `prestress`, rebar off the assembly.
Defensive by construction, not by a wrapping try/except around the whole
thing: the two halves run in SEPARATE try blocks, so a rebar-side failure
(no assembly, `ticketqc` unavailable -- true on host CPython, where
`vibe.ticketqc` cannot even import) never takes the strand half down with
it, and vice versa. Never raises.
"""
out = {}
try:
out.update(strand_prefill(prestress, None, fpu_ksi))
except Exception:
pass
try:
from vibe import ticketqc as tq
frame = tq._panel_frame(el)
boxes = tq.member_boxes(el.Document, asm, el, frame, know) or []
out.update(rebar_prefill(boxes, know))
out.update(mild_bar_rows_prefill(el, asm, know, boxes))
except Exception:
pass
return out