# -*- 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