# -*- 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, []