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,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
|
||||
Reference in New Issue
Block a user