Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:35:15 -05:00

384 lines
14 KiB
Python

# -*- coding: utf-8 -*-
"""Panel Analysis: pre-fill the Handling page's Face / Edge lifter fields off
what Panel Lift already measured on the assembly.
`liftexport.export()` already reads every lifter on the piece into
`job['panel']['lifters']` -- family, mark, `x` (ft along the panel LENGTH,
from the end that ends up lower), `y` (ft across the WIDTH), `on_edge`, and
more. The Handling page's `face_lifters` / `edge_lifters` text fields
(`panelanalysisform.FIELDS`) were never pre-filled from that even though the
data was sitting right there -- this module is the missing hop.
FACE VS EDGE is the plant's own grouping, read out of `knowledge` the same
way the rest of the tool reads it:
handling.lifter_groups.face.families -- `knowledge._is_face_lifter`
handling.lifter_groups.edge_top.families -- substring match, same way
A lifter is FACE when `knowledge._is_face_lifter` says its family is one.
Otherwise it is EDGE when either the model already measured it `on_edge`, or
its family matches `edge_top.families`. A family that matches neither list
and was not measured on an edge is a family the plant has not grouped --
`liftexport._group_of` calls the same case out as `ungrouped` -- and this
module treats it the same way: it is reported in `why`, and written to
neither field, rather than guessed at.
Nothing here touches Revit or the live document -- it is pure formatting
over data `liftexport.export()` already produced, exactly like
`panelanalysisform.parse_strand_rows` is pure over strand text.
"""
from vibe import knowledge
def _frac(y, width_ft):
"""y / width_ft, or None when either is missing or width is zero."""
if y is None or not width_ft:
return None
try:
return float(y) / float(width_ft)
except (TypeError, ValueError, ZeroDivisionError):
return None
def _is_edge_top_family(know, family_name):
"""Same substring match as `knowledge._is_face_lifter`, against
`handling.lifter_groups.edge_top.families` instead of `.face.families`.
"""
up = (family_name or '').upper()
families = knowledge.get(
know, 'handling.lifter_groups.edge_top.families', []) or []
for fam in families:
token = knowledge.as_text(fam).strip().upper()
if token and token in up:
return True
return False
def _classify(know, lifter):
"""'face' / 'edge' / None for one lifter dict, per the module docstring."""
family = lifter.get('family')
if knowledge._is_face_lifter(know, family):
return 'face'
if lifter.get('on_edge') or _is_edge_top_family(know, family):
return 'edge'
return None
def _fmt_rows(rows):
"""rows: list of (x_ft, frac) -> one '{x:.2f} @ {frac:.2f}' per line,
sorted by x then frac. Empty input -> ''."""
ordered = sorted(rows, key=lambda r: (r[0], r[1]))
return '\n'.join('{0:.2f} @ {1:.2f}'.format(x, frac)
for x, frac in ordered)
def lifter_lines(lifters, width_ft, know):
"""(face_text, edge_text, why) off a list of Panel Lift lifter dicts.
One line per lifter -- '{x:.2f} @ {frac:.2f}' where frac = y / width_ft,
lines sorted by (x, frac). Several lifters sharing an x is a row (a face
pair, an edge group) and is expected -- it produces one line per lifter,
not one per row.
A lifter whose x or y did not measure (either is None), or whose frac
could not be computed (no width), is skipped -- not placed on a guess --
and counted in `why` rather than silently dropped. A lifter whose family
matches neither the face nor the edge_top group is also left off both
texts and counted in `why`, by family name, the same way
`liftexport.export`'s own `ungrouped` problem names one.
`why` is one sentence naming how many face / edge lifters came from which
families, plus what was skipped.
"""
lifters = lifters or []
face_rows = []
edge_rows = []
face_families = set()
edge_families = set()
unclassified = set()
skipped = 0
for lifter in lifters:
x = lifter.get('x')
y = lifter.get('y')
frac = _frac(y, width_ft)
if x is None or frac is None:
skipped += 1
continue
group = _classify(know, lifter)
family = lifter.get('family')
if group == 'face':
face_rows.append((x, frac))
face_families.add(family)
elif group == 'edge':
edge_rows.append((x, frac))
edge_families.add(family)
else:
unclassified.add(family)
face_text = _fmt_rows(face_rows)
edge_text = _fmt_rows(edge_rows)
def _plural(n):
return '' if n == 1 else 's'
sentence = (
'{0} face lifter{1}{2} and {3} edge lifter{4}{5} read off the '
'assembly'.format(
len(face_rows), _plural(len(face_rows)),
' ({0})'.format(', '.join(sorted(f for f in face_families if f)))
if face_families else '',
len(edge_rows), _plural(len(edge_rows)),
' ({0})'.format(', '.join(sorted(f for f in edge_families if f)))
if edge_families else ''))
extras = []
if unclassified:
extras.append(
'{0} lifter{1} matched neither the face nor the edge_top group '
'({2})'.format(len(unclassified), _plural(len(unclassified)),
', '.join(sorted(f for f in unclassified if f))))
if skipped:
extras.append(
'{0} lifter{1} skipped (no measured position)'.format(
skipped, _plural(skipped)))
why = sentence
if extras:
why += '; ' + '; '.join(extras)
why += '.'
return face_text, edge_text, why
# How close to an END (x near 0 or near length_ft) still counts as 'top' /
# 'bottom' rather than a mid-length edge lifter -- half a foot, the same
# order of magnitude `liftexport.rows_of`'s own row-grouping tolerance uses
# for "close enough to be the same station".
_END_TOL_FT = 0.5
# How close to a SIDE (frac near 0 or 1) still counts as 'left' / 'right'.
_SIDE_TOL_FRAC = 0.05
def _edge_direction(x, frac, length_ft):
"""'top' / 'bottom' / 'left' / 'right' for one EDGE lifter, or None when
it sits at neither an end nor a side closely enough to call.
END wins over SIDE when both could apply (a true corner lifter): the
piece's own two ends are the more specific fact, and this module's
whole reason for existing is a wall whose edge lifters sat at the END
(x = length) and were being reported as generic 'edge' with no end/side
distinction at all.
"""
if length_ft:
try:
if x is not None and abs(x - 0.0) <= _END_TOL_FT:
return 'bottom'
if x is not None and abs(x - length_ft) <= _END_TOL_FT:
return 'top'
except TypeError:
pass
if frac is not None:
if abs(frac - 0.0) <= _SIDE_TOL_FRAC:
return 'left'
if abs(frac - 1.0) <= _SIDE_TOL_FRAC:
return 'right'
return None
def edge_lifter_lines(lifters, width_ft, length_ft, know):
"""(top_text, side_text, why) -- the same EDGE lifters `lifter_lines`
already finds, split again by which edge they are really on.
Same 'x @ frac' grammar and (x, frac) sort as `lifter_lines`; `top_text`
carries the 'top' + 'bottom' lifters, `side_text` the 'left' + 'right'
ones. An edge lifter whose x/frac reads but calls neither direction
(`_edge_direction` returns None -- it sits at neither an end nor a side
within tolerance) is left off BOTH texts and counted in `why`, never
placed on a guess; it still appears in `lifter_lines`' own `edge_text`.
"""
lifters = lifters or []
top_rows = []
side_rows = []
top_dirs = set()
side_dirs = set()
uncalled = 0
for lifter in lifters:
x = lifter.get('x')
y = lifter.get('y')
frac = _frac(y, width_ft)
if x is None or frac is None:
continue
if _classify(know, lifter) != 'edge':
continue
direction = _edge_direction(x, frac, length_ft)
if direction in ('top', 'bottom'):
top_rows.append((x, frac))
top_dirs.add(direction)
elif direction in ('left', 'right'):
side_rows.append((x, frac))
side_dirs.add(direction)
else:
uncalled += 1
top_text = _fmt_rows(top_rows)
side_text = _fmt_rows(side_rows)
def _plural(n):
return '' if n == 1 else 's'
why = (
'{0} end (top/bottom) edge lifter{1}{2} and {3} side (left/right) '
'edge lifter{4}{5}, split from the edge lifters above by x within '
'{6:g} ft of a panel end or frac within {7:g} of 0/1'.format(
len(top_rows), _plural(len(top_rows)),
' ({0})'.format(', '.join(sorted(top_dirs))) if top_dirs else '',
len(side_rows), _plural(len(side_rows)),
' ({0})'.format(', '.join(sorted(side_dirs))) if side_dirs
else '',
_END_TOL_FT, _SIDE_TOL_FRAC))
if uncalled:
why += ('; {0} edge lifter{1} matched neither an end nor a side '
'closely enough to call'.format(uncalled, _plural(uncalled)))
why += '.'
return top_text, side_text, why
def read_lifters(job, know):
"""{'face_lifters', 'edge_lifters', 'top_edge_lifters',
'side_edge_lifters', 'lifters_basis', 'edge_direction_basis'} for the
Handling page, straight off `job['panel']['lifters']` -- Panel Lift
already measured every lifter on the assembly; this only reformats it
for the text fields.
`top_edge_lifters` / `side_edge_lifters` are the SAME edge lifters
`edge_lifters` already carries, split again by which edge they are
really on (see `edge_lifter_lines`) -- kept ALONGSIDE `edge_lifters`,
never replacing it, for whatever still reads the older undivided field.
Every one of the four text keys is omitted when its own text is empty;
`lifters_basis` / `edge_direction_basis` are always present alongside
them so a blank field still says why. `{}` when `job` is None or carries
no lifters at all, and defensively `{}` on any exception -- a pre-fill
that guesses wrong is worse than a blank field the owner fills by hand.
"""
try:
if not job:
return {}
panel = job.get('panel') or {}
lifters = panel.get('lifters')
if not lifters:
return {}
width_ft = panel.get('width_ft')
length_ft = panel.get('length_ft')
face_text, edge_text, why = lifter_lines(lifters, width_ft, know)
out = {'lifters_basis': why}
if face_text:
out['face_lifters'] = face_text
if edge_text:
out['edge_lifters'] = edge_text
top_text, side_text, edge_why = edge_lifter_lines(
lifters, width_ft, length_ft, know)
out['edge_direction_basis'] = edge_why
if top_text:
out['top_edge_lifters'] = top_text
if side_text:
out['side_edge_lifters'] = side_text
return out
except Exception:
return {}
def read_dunnage(job):
"""{'dunnage_positions_ft', 'dunnage', 'dunnage_basis'} off Panel Lift's
own job, or {}.
The support LINE positions `liftexport.dunnage_supports` already worked
out for whichever handling stage sits the piece down on dunnage instead
of hanging it from its lifters (`stage['supports']`, written only for
the stage(s) whose `pci_case` is a sit-down case -- today
'transportation' -- and only when the plant has configured
`handling.dunnage`; see that function's own docstring). Nothing here
re-derives a support position: it is read straight off the job Panel
Lift already built, the first stage that carries one.
`dunnage_positions_ft` is the raw list, feet from the panel's own x = 0,
ascending. `dunnage` is the SAME list formatted as the one-number-per-
line text `pa_sketch_handling._dunnage_positions` (and the Handling
page's own `dunnage` field) already parses -- so a plant that has
configured dunnage gets the field pre-filled instead of blank, and one
that has not gets neither key at all (not a guessed pair of quarter
points).
`{}` when `job` is None, no stage carries a `supports` list, or on any
exception.
"""
try:
if not job:
return {}
stages = job.get('stages') or []
xs = None
stage_name = None
for stage in stages:
supports = stage.get('supports')
if supports:
xs = sorted(round(float(x), 4) for x in supports)
stage_name = stage.get('name') or stage.get('pci_case') \
or 'stage'
break
if not xs:
return {}
return {
'dunnage_positions_ft': xs,
'dunnage': '\n'.join('{0:.2f}'.format(x) for x in xs),
'dunnage_basis': (
'{0} dunnage support position(s), feet from the panel\'s '
'own x = 0, read off Panel Lift\'s own job (handling.'
'dunnage, stage {1!r}) -- not re-measured here.'.format(
len(xs), stage_name)),
}
except Exception:
return {}
def parse_lifter_lines(text):
"""(rows, problems) -- one lifter per LINE, 'x_ft @ frac'.
All-or-nothing by line, the same discipline as
`panelanalysisform.parse_strand_rows`: any line that fails to parse
refuses the WHOLE field (`rows` comes back `[]`) rather than keeping the
lines that did parse -- a lifter schedule half-read is not a lifter
schedule. Blank text -> ([], []).
"""
rows = []
problems = []
if text is None:
text = ''
for i, raw in enumerate(text.splitlines(), 1):
line = raw.strip()
if not line:
continue
parts = [p.strip() for p in line.split('@')]
if len(parts) != 2:
problems.append(
"line {0}: expected 'x_ft @ frac', got {1!r}".format(
i, line))
continue
x_txt, frac_txt = parts
try:
x_ft = float(x_txt)
except (TypeError, ValueError):
problems.append(
'line {0}: x_ft is not a number ({1!r})'.format(i, x_txt))
continue
try:
frac = float(frac_txt)
except (TypeError, ValueError):
problems.append(
'line {0}: frac is not a number ({1!r})'.format(i, frac_txt))
continue
rows.append({'x_ft': x_ft, 'frac': frac})
if problems:
return [], problems
return rows, []