# -*- coding: utf-8 -*- """The Panel Analysis form's "Handling" section, rendered as generic blocks. WHY THIS EXISTS: the owner has now said three times that the Panel Analysis input form is too ugly and unreadable to use ("太丑了", "你这么输入用户上哪儿能 看懂"), and the last ruling was blunt about the fix -- "全部给我做成普通人能看懂 的样子", make every part of it something an ordinary person can understand. The "Handling" section is the worst offender: it used to be three lonely text boxes (`strip_mult` / `transport_mult` / `erection_mult`) even though Eriksson Wall's own printed page for this section is a whole per-stage MATRIX -- eleven questions asked once per handling stage, plus lifter/dunnage tables and a plan drawing. `vibe.panelanalysisform` was rebuilt first (its own docstring covers that ruling and the Eriksson-parity one before it) and now exposes a real stage matrix (`STAGES`, `STAGE_OPTIONS`, `stage_matrix`, `stage_field_key`). This module is the second half: turning that data plus the plain-language field metadata into the flat, renderer-agnostic BLOCK list a generic WPF page consumes, matching Eriksson's own page 25/26 layout (Stage Options matrix + legend + weight/centroid + plan drawing + lifter and dunnage tables + Lifting Hardware) as closely as the two modules' real data will honestly support. THE BLOCK VOCABULARY IS FIXED and belongs to the page renderer, not to this module -- 'heading' / 'columns' / 'field' / 'matrix' / 'table' / 'legend' / 'kv' / 'sentence' / 'drawing' / 'rule' / 'space', nothing else. An unknown `kind` renders as nothing, so a typo here is a silently blank section rather than a crash; `page()` is tested against exactly that vocabulary for that reason. READ `vibe.panelanalysisform` DEFENSIVELY. Another agent owns that module and was mid-restructure of exactly the stage matrix this file depends on while this file was written. Every accessor this module leans on -- `STAGES` / `STAGE_OPTIONS` / `stage_field_key` / `FIELDS` / `label_of` / `symbol_of` / `help_of` -- is reached through `getattr`/`hasattr` or a try/except, never assumed. A shape that is not there yet (or has changed shape) becomes one plain-English sentence in `problems`, and the rest of the page still renders around the hole -- never a raised exception, and never an invented value standing in for the one that could not be read. This is the same "could not judge" vs "judged, found nothing" discipline as everywhere else in this codebase (see CLAUDE.md SS3): a missing accessor is reported by name, not silently skipped and not guessed at. WHAT COULD NOT BE BUILT FROM REAL DATA, and why it is a `problems` entry instead of a fabricated block: Eriksson's own "Handling" pages carry several things `vibe.panelanalysisform` does not model yet -- * a per-lifter STRUCTURED row (quantity, longitudinal position, two transverse positions, a tick per stage, "on top face") for Face / Side Edge Lifters. The form only has `face_lifters` / `edge_lifters` as two free-text fields ("column @ fraction of width", one line per lifter), so the structured table is rendered as those two text fields instead of an invented set of per-row keys nothing in the form can parse or validate. * a dedicated "top edge lifters" field. There isn't one -- only `face_lifters` / `edge_lifters` exist -- so the section renders a sentence saying so, in the same voice Eriksson's own page uses for "no top edge lifters on this design" (never a blank, per CLAUDE.md SS3's "nothing here" vs "never asked" rule -- an omitted field and an empty one must not look the same). * a "Dunnage" field of any kind. Same treatment. * the computed Panel Weight / Centroid block. `weight_lb` is a real field (the user's override, or what the model could read) so its row renders from `values`/`reads`; the three centroid components (X from start, Y from bottom, Z from left) are not produced anywhere in this pipeline yet, so those rows say so in plain words rather than printing a fabricated 0.000. * the "Lifting Configurations" sketches on Eriksson's second page (one small diagram per included stage, each with its own G/CG marker) and the "Face Lifters / Name / Capacity Method" block above them. No `panelsketch` entry point for either exists (only `plan` and `weight_diagram` do, per the sibling agent adding them), and no panelanalysisform field carries a lifter name or capacity-check method -- so both become one `problems` entry apiece and a short explanatory sentence, not an invented drawing source or an invented field. THE STAGE MATRIX'S "DOES NOT APPLY" COLUMNS are a STRUCTURAL fact about which questions make sense for which stage, not a report of which stage happens to be ticked on right now. Reading Eriksson's own page 25 by column: Rigging Configuration / Sling Angle / Add Ground Reaction / Minimum-Maximum Tilt Angle / Assume Top Edge Lifters Carry Panel Weight are answered only for the two erection stages (Primary, Secondary) and dashed for Stripping and Transportation in EVERY sample seen, independent of whether a given erection stage is itself switched on -- a crane pick has a rigging configuration and a tilt range; a stripping pull or a truck ride does not. `_ERECTION_ONLY` below is that fixed structural map. It is deliberately NOT keyed off each stage's own `include` flag: the owner's rule for `panelanalysisform._parse_stage_matrix` is that switching a stage off must never make its OTHER cells un-requestable or drop what the user already typed into them, and printing an em dash over a cell the user could still type into would do exactly that. So the only cells this module ever marks `{'na': True}` are ones no stage state can turn back on; a merely-unticked stage's cells stay ordinary editable cells, matrix-wide. Standard-library only. No Revit, no WPF, no `panelsketch` import (a source name is enough -- see the block vocabulary's 'drawing' kind). Runs on IronPython 2.7 in the button and on plain CPython in `tests/`: no f-strings, no dataclasses/typing/enum, `'{0}'.format(...)` throughout. """ # Editor kind a FIELDS-style `kind` renders as, for the plain fields this # section pulls straight from `vibe.panelanalysisform.FIELDS` (weight_lb, # self_weight_factor, face_lifters, edge_lifters). Anything not listed here # falls back to a free-text box -- 'supports' / 'openings' / 'strands' are # all typed as structured-but-still-one-text-box fields on the real form, and # nothing in the Handling section uses them, but the fallback keeps this # honest if that ever changes. _EDITOR_OF_KIND = { 'text': 'text', 'num': 'num', 'int': 'num', 'supports': 'text', 'openings': 'text', 'strands': 'text', } # Fallback choice vocabulary for the free-text stage-matrix options, used # ONLY when `STAGE_OPTIONS`'s own row is the older 6-tuple shape with no # choices slot of its own (see `_unpack_stage_option`). Once the row carries # its own choices, that is authoritative and this is never consulted -- two # sources of truth for the same list would drift. These are OFFERED choices, # never a default -- the current value still comes from whatever is in # `values[key]`, blank if nothing was typed. Picking one is the user's # decision; the owner's instruction is explicit that none of them stand in # as a default. _STAGE_CHOICES_FALLBACK = { 'lift_type': ['Static Lift', 'Rolling Block'], 'concrete_strength': ['Initial', 'In-Place'], 'stress_limits': ['Handling', 'In-Place'], 'rigging': ['Auto'], } def _unpack_stage_option(opt): """(opt_key, label, symbol, help, kind, choices) from one STAGE_OPTIONS row, or None if the row cannot be read at all. Defensive against exactly the kind of mid-refactor shape change this module was written under: `STAGE_OPTIONS` grew a seventh `choices` slot on live disk between this module's first draft and this test run (a 7-tuple with the new slot, where the 6-tuple it replaced had none). Both shapes are accepted; `choices` falls back to `_STAGE_CHOICES_FALLBACK` only for the older shape, and is `None`/`[]` for an option with no fixed vocabulary either way. `drives` (index 5 of the 7-tuple) is not needed here -- NA-ness is decided structurally by `_ERECTION_ONLY`, not by `drives` -- so it is simply not unpacked.""" try: opt_key, label, symbol, help_text, kind, _drives, choices = opt choices = list(choices) if choices else None return opt_key, label, symbol, help_text, kind, choices except (TypeError, ValueError): pass try: opt_key, label, symbol, help_text, kind, _drives = opt choices = _STAGE_CHOICES_FALLBACK.get(opt_key) return opt_key, label, symbol, help_text, kind, choices except (TypeError, ValueError): return None # The stage-matrix options that only make sense for a stage where the panel # is actually being picked up and set (Primary / Secondary Erection). See the # module docstring's "STRUCTURAL fact" paragraph -- this is NOT about whether # a given stage is ticked on. _ERECTION_ONLY = set([ 'rigging', 'sling_angle', 'ground_reaction', 'min_tilt_deg', 'max_tilt_deg', 'top_crane_carries', ]) _NOT_ERECTION_STAGES = set(['Stripping', 'Transportation']) # Fields this section pulls straight out of `vibe.panelanalysisform.FIELDS` # by key, grouped the way this page presents them. Every key here is checked # against the real module before use -- see `_field_block`. _LOAD_FACTOR_KEYS = ('weight_lb', 'self_weight_factor') _LIFTER_TEXT_KEYS = ('face_lifters', 'edge_lifters') def _import_paf(): """The real `vibe.panelanalysisform`, or (None, reason). A separate function so `page()` reads like the rest of this module: one early check, one plain-English reason, never a raised ImportError. """ try: from vibe import panelanalysisform as paf except ImportError as exc: return None, ('vibe.panelanalysisform could not be imported ' '({0}); the Handling section cannot be built at ' 'all'.format(exc)) return paf, None def _field_exists(paf, key): """Whether `key` is a real entry in `paf.FIELDS` right now. Never assumes `FIELDS` itself is present or list-shaped -- the module this reads from is being restructured by another agent as this is written. """ fields = getattr(paf, 'FIELDS', None) if not fields: return False try: for f in fields: if isinstance(f, dict) and f.get('key') == key: return True except TypeError: return False return False def _field_kind(paf, key): for f in getattr(paf, 'FIELDS', None) or []: if isinstance(f, dict) and f.get('key') == key: return f.get('kind') or 'text', bool(f.get('required')) return 'text', False def _field_block(paf, key): """A `{'kind': 'field', ...}` block for a real `panelanalysisform` key. Caller must have already confirmed `_field_exists(paf, key)`. Labels, symbols and hints all come from the module's own accessors rather than being re-typed here, so this page can never drift from the field it is describing -- and those accessors already degrade to the bare key if something about the field record is missing, so this cannot raise. """ label = paf.label_of(key) if hasattr(paf, 'label_of') else key symbol = paf.symbol_of(key) if hasattr(paf, 'symbol_of') else '' hint = paf.help_of(key) if hasattr(paf, 'help_of') else '' kind, required = _field_kind(paf, key) editor = _EDITOR_OF_KIND.get(kind, 'text') return { 'kind': 'field', 'key': key, 'label': label, 'symbol': symbol or None, 'hint': hint or None, 'required': required, 'editor': editor, 'choices': None, } def _row_label(label, symbol): """'label — symbol', matching `panelanalysisform.display_label`'s own join -- the symbol rides beside the plain English, never replaces it.""" if symbol: return u'{0} — {1}'.format(label, symbol) return label def _stage_options_editor(kind): if kind == 'bool': return 'check' if kind == 'num': return 'num' return 'text' def _stage_matrix_block(paf, problems): """The Stage Options matrix block, or None if it cannot be built. Reads `STAGES` and `STAGE_OPTIONS` off `paf` defensively -- each missing or malformed piece is one `problems` sentence naming it, and a row that cannot be unpacked is skipped rather than raising, so a partial restructure of the source module degrades this one matrix instead of the whole page. """ stages = list(getattr(paf, 'STAGES', ()) or ()) if not stages: problems.append('vibe.panelanalysisform has no STAGES list yet; ' 'the Stage Options matrix cannot be built') return None options = getattr(paf, 'STAGE_OPTIONS', None) if not options: problems.append('vibe.panelanalysisform has no STAGE_OPTIONS list ' 'yet; the Stage Options matrix cannot be built') return None stage_field_key = getattr(paf, 'stage_field_key', None) if not callable(stage_field_key): problems.append('vibe.panelanalysisform has no stage_field_key() ' 'yet; the Stage Options matrix cells have no keys ' 'to write to') return None rows = [] for opt in options: unpacked = _unpack_stage_option(opt) if unpacked is None: problems.append('a Handling stage option row has an unexpected ' 'shape ({0!r}); skipped'.format(opt)) continue opt_key, label, symbol, help_text, kind, choices = unpacked editor = _stage_options_editor(kind) cells = [] for stage in stages: if opt_key in _ERECTION_ONLY and stage in _NOT_ERECTION_STAGES: cells.append({'na': True}) continue try: key = stage_field_key(stage, opt_key) except Exception as exc: # A broken key builder must not take the whole page down; # name it and leave that one cell blank instead. problems.append( 'could not build the form key for {0} / {1} ({2}); that ' 'cell is left blank'.format(stage, label, exc)) cells.append(None) continue cells.append({'editor': editor, 'key': key, 'choices': choices}) rows.append({'label': _row_label(label, symbol), 'hint': help_text or None, 'cells': cells}) if not rows: problems.append('every Handling stage option row was unreadable; ' 'the Stage Options matrix has nothing to show') return None return {'kind': 'matrix', 'columns': stages, 'rows': rows} def _legend_block(): """Eriksson's own two-column legend, redrawn generically. Shape names ('triangle', 'bar') and fill names ('blank', 'light', 'hatch', 'solid', 'tan') are markers for the renderer to draw, not engineering values -- there is nothing here for `panelanalysisform` to supply, so this block is static. """ return { 'kind': 'legend', 'groups': [ ('Shapes', [ ('triangle', 'Face lifter'), ('bar', 'Dunnage'), ]), ('Fills', [ ('blank', 'Not specified'), ('light', 'Stripping'), ('hatch', 'Erection'), ('solid', 'Stripping + Erection'), ('tan', 'Transportation'), ]), ], } def _weight_centroid_block(paf, values, reads, problems): """The Panel Weight / Centroid kv block. Weight comes from the real `weight_lb` field -- the user's override, or whatever the model could read, exactly like every other prefilled field on this form -- converted to kips to match Eriksson's own display. The three centroid components are not computed anywhere in this pipeline yet, so they say that in plain words rather than printing a fabricated 0.000; see the module docstring. """ weight_row = ('Panel weight', 'not entered yet -- computed from the ' 'geometry above once you run the analysis') if _field_exists(paf, 'weight_lb'): text = (values.get('weight_lb') or '').strip() if not text and reads: text = paf._fmt(reads.get('weight_lb')) if hasattr(paf, '_fmt') \ else str(reads.get('weight_lb') or '') if text: try: kips = float(text) / 1000.0 weight_row = ('Panel weight', '{0:.3f} kips (as entered)'.format(kips)) except (TypeError, ValueError): weight_row = ('Panel weight', '{0} lb (as entered, not a plain number)' .format(text)) else: problems.append('vibe.panelanalysisform has no weight_lb field; ' 'panel weight cannot be shown') not_tracked = 'not tracked on this form yet' problems.append('panel centroid (X from start / Y from bottom / Z ' 'from left) is not computed anywhere in this pipeline ' 'yet; the Panel Weight / Centroid block shows weight ' 'only') return { 'kind': 'kv', 'rows': [ weight_row, ('Centroid, X (from start)', not_tracked), ('Centroid, Y (from bottom)', not_tracked), ('Centroid, Z (from left)', not_tracked), ], } def _lifter_section_blocks(paf, problems): """Face / Side Edge Lifters, Top Edge Lifters, Dunnage -- three blocks. Only `face_lifters` / `edge_lifters` are real fields; the structured per-lifter table Eriksson prints (qty, two positions, a tick per stage) has no backing data model here, and "top edge lifters" / "dunnage" have no field at all. See the module docstring for why each of those is a sentence and a `problems` entry rather than an invented table. """ blocks = [{'kind': 'heading', 'text': 'Face / Side Edge Lifters', 'level': 2}] any_lifter_field = False for key in _LIFTER_TEXT_KEYS: if _field_exists(paf, key): blocks.append(_field_block(paf, key)) any_lifter_field = True else: problems.append('vibe.panelanalysisform has no {0} field; that ' 'lifter position cannot be entered'.format(key)) if any_lifter_field: blocks.append({ 'kind': 'sentence', 'text': ('Eriksson prints one row per lifter with a quantity, ' 'two positions and a tick per stage; this form does ' 'not yet parse the text above into that shape, so ' 'each line is kept as typed.'), }) blocks.append({'kind': 'heading', 'text': 'Top Edge Lifters', 'level': 2}) if _field_exists(paf, 'top_edge_lifters'): # Not expected today, but if a future restructure adds the field, # render it rather than silently keeping the old sentence. blocks.append(_field_block(paf, 'top_edge_lifters')) else: problems.append('vibe.panelanalysisform has no top edge lifters ' 'field; this section always reads as empty') blocks.append({'kind': 'sentence', 'text': 'Design contains no top edge lifters.'}) blocks.append({'kind': 'heading', 'text': 'Dunnage', 'level': 2}) if _field_exists(paf, 'dunnage'): blocks.append(_field_block(paf, 'dunnage')) else: problems.append('vibe.panelanalysisform has no dunnage field; ' 'this section always reads as empty') blocks.append({'kind': 'sentence', 'text': 'Design contains no dunnage entered.'}) return blocks def _lifting_hardware_blocks(problems): """Eriksson page 26: the weight-distribution drawing plus a note on what could not be carried over (see the module docstring's last bullet).""" blocks = [ {'kind': 'heading', 'text': 'Lifting Hardware', 'level': 2}, {'kind': 'drawing', 'source': 'weight_diagram', 'caption': 'PANEL WEIGHT DISTRIBUTION', 'w': 760, 'h': 220}, ] problems.append( 'Eriksson also prints a small lifting-configuration sketch per ' 'included stage (a G/CG marker over each stage\'s lift points) ' 'and a Face Lifters name / capacity-check method block above the ' 'weight distribution; neither has a panelsketch drawing source or ' 'a panelanalysisform field yet, so neither is shown') return blocks def page(values, reads=None): """(blocks, problems) for the Handling section of the Panel Analysis form. `values` is the form's current string-valued dict, read-only here -- this function never assigns into it, so the caller's dict comes back unchanged no matter what this renders. `reads` is the optional dict of what the model could read off the framing element (the same shape `panelanalysisform.prefill` takes); it is used only as a fallback for the weight row when `values` itself is blank, and its absence never raises. Returns a flat list of block dicts and a list of plain-English problem sentences -- never raises, per the module docstring's "could not judge" discipline. An empty `problems` list means every accessor this section depends on was present and shaped as expected; a non-empty one names every gap, so the caller can decide whether to surface it to the user (a stale accessor is worth knowing about even though the page itself still renders around it). """ problems = [] paf, reason = _import_paf() if paf is None: problems.append(reason) return ([{'kind': 'heading', 'text': 'Handling', 'level': 1}, {'kind': 'sentence', 'text': ('The Handling section could not be built -- see ' 'the problem above.')}], problems) values = values or {} blocks = [{'kind': 'heading', 'text': 'Handling', 'level': 1}] # --- Stage Options matrix ------------------------------------------- blocks.append({'kind': 'heading', 'text': 'Stage Options', 'level': 2}) matrix_block = _stage_matrix_block(paf, problems) if matrix_block is not None: blocks.append(matrix_block) else: blocks.append({'kind': 'sentence', 'text': ('The per-stage handling options could not ' 'be built -- see the problem above.')}) blocks.append({'kind': 'space', 'h': 8}) blocks.append(_legend_block()) blocks.append({'kind': 'space', 'h': 8}) # --- Panel Weight / Centroid ------------------------------------------ blocks.append({'kind': 'heading', 'text': 'Panel Weight & Centroid', 'level': 3}) blocks.append(_weight_centroid_block(paf, values, reads, problems)) blocks.append({'kind': 'rule'}) # --- Load factors (weight override / self-weight factor) -------------- load_factor_blocks = [] for key in _LOAD_FACTOR_KEYS: if _field_exists(paf, key): load_factor_blocks.append(_field_block(paf, key)) else: problems.append('vibe.panelanalysisform has no {0} field' .format(key)) if load_factor_blocks: blocks.append({'kind': 'heading', 'text': 'Load Factors', 'level': 3}) blocks.extend(load_factor_blocks) blocks.append({'kind': 'space', 'h': 8}) # --- plan drawing ------------------------------------------------- blocks.append({'kind': 'drawing', 'source': 'plan', 'caption': 'LIFTER AND DUNNAGE DETAIL', 'w': 760, 'h': 420}) blocks.append({'kind': 'space', 'h': 8}) # --- lifter / dunnage sections ----------------------------------- blocks.extend(_lifter_section_blocks(paf, problems)) blocks.append({'kind': 'rule'}) # --- Lifting Hardware (page 26) ------------------------------------ blocks.extend(_lifting_hardware_blocks(problems)) return blocks, problems