25592fa6c2
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.
762 lines
33 KiB
Python
762 lines
33 KiB
Python
# -*- 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
|