8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
384 lines
14 KiB
Python
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, []
|