# -*- coding: utf-8 -*- """Panel Analysis input form -- pure logic, both Pythons, no Revit. The Panel Analysis button pops a confirmation form: one collapsible section per selected structural-framing panel, each pre-filled with what the model could read and BLANK where it could not, and each gated so the analysis cannot run until the fields it needs are filled (the owner's rule, 2026-09-02: "按完以后出一个输入界面,在这个界面里面预填充能读到的数据,读不到就留空白, 用户不输入就不让用户跑分析。" Press, then a form; pre-fill what can be read, blank what cannot; do not let the user run the analysis until they have filled it in.) THE FIELDS REPLICATE ERIKSSON WALL'S OWN INPUT SECTIONS, verbatim-named (owner, 2026-09-02: "表单照着eriksson wall抄 ... Design Criteria, Concrete Extents, Structural Model, Handling, Reinforcement 这几个板块,都给我照着复刻"). The field list is taken from Eriksson's actual per-section inputs (`docs/eriksson-wall-method.md` §A.1 and the extracted golden samples), NOT invented -- but every LABEL and value here is generic precast nomenclature; no client, job or sample value is carried. The five section names are the whole of what "照着复刻" fixed; the GROUPS inside each section are new, added to mirror Eriksson's own page-by-page blocks (Material Properties / Prestress Losses / Load Combinations inside Design Criteria; a Stage Options matrix inside Handling; and so on) without inventing a sixth top-level section. THE SECOND OWNER RULING THIS MODULE ANSWERS, 2026-09-02, is legibility, not completeness: "全都做掉。全部给我做成普通人能看懂的样子" after twice calling the form unreadable ("太丑了", "你这么输入用户上哪儿能看懂"). A field used to carry ONLY its engineering name -- `Handling tension limit (n·√f′ci)` -- as if the symbol were the explanation. Every field below instead carries a plain-English `label` a non-engineer can read, an engineering `symbol` beside it (never instead of it -- the Unicode math is not decoration, it is what a reviewing engineer checks against Eriksson's own printout), and a one-sentence `help` string saying in words what the field is and what happens if it is left blank. `display_label(key)` is what an error message names a field by -- label AND symbol together, so a person who thinks in either vocabulary can find the field the message means. Each field declares which Eriksson SECTION (and, inside it, which GROUP) it belongs to, and whether it `drives` the Stage-0 self-weight solve: 'solve' -- consumed by the `panelanalysis/1` deck the Stage-0 tool reads. 'stage3' -- a real Eriksson input the Stage-0 solve does NOT use yet (in-place wind / restraints / mild steel / prestress-loss method / stage rigging / ...). Shown so the form is Eriksson-complete, marked as not-yet-driving-analysis, never faked into a result and never dropped. 'info' -- a reference/computed value (section type, etc.). This module is the SEAM between the WPF form (owner-must-verify, in Revit) and the deck. It imports no Revit and no `pyrevit`, so it loads on plain CPython the way the rest of the Revit-free `vibe` does (see `tests/conftest.py`), and it must run under IronPython 2.7 in the button: no f-strings, no typing, no dataclasses. WHY THE DECK IS BUILT BY HAND HERE, not through `panelanalysis_schema`: that schema imports `panellift` (CPython dataclasses) and cannot be imported from IronPython -- the same boundary `liftexport.py` builds the `panellift/1` job dict by hand across. `build_deck`'s shape mirrors `panelanalysis_schema. new_deck`, and the host tests feed it straight back through `schema.to_panel` + the solve to prove the two agree. HOW OPENINGS REACH `blockouts` / `recesses` (wired 2026-09-04, `_openings_ for_deck`): `panelanalysis_schema.to_panel` feeds `concrete_extents. blockouts` / `.recesses` straight into `panellift.cli.read_panel`, which expects `{name, x0, y0, length, width, depth_in[, concrete_in]}` -- a DIFFERENT shape than the `{pos_along, pos_along_from, length, pos_cross, pos_cross_from, width, pos_vert, height, label}` rows this form collects (Eriksson's own Openings/Reveals/Solid-Zones table columns). The AXIS ASSIGNMENT is a judgement call, the SAME one `panelsketch._parse_opening_row` already made and flagged (medium confidence on the cross/width axis -- there is nothing else in the codebase to check it against): `pos_along`/`length` is the panel LENGTH axis (`x0`/`length`, high confidence -- 'along' unambiguously means along the panel); `pos_vert`/`height` (always measured from the bottom -- there is no `_from` word for it) is the panel WIDTH axis (`y0`/`width`, matching `panellift.model.Blockout`'s own golden example, whose `y0` sits inside `[0, width_ft]`); `pos_cross`/`width`/`pos_cross_from` (left/right) is the THROUGH-THICKNESS extent. An 'Opening' row always goes full depth (`depth_in=None`) regardless of what is typed there -- a hole is a hole, clean through, in Eriksson's own usage; a 'Reveal' row's `depth_in` IS that extent, in inches (this form's universal feet, x12); `concrete_in` (how much is left behind a reveal) has no field on this form and is left `None` -- unmeasured, not zero. 'Solid Zone' rows have no `panellift` shape (neither a hole nor a blind pocket) and are left out of both lists, counted in a `concrete_extents._openings_note` sentence rather than silently dropped. WHAT IS NOT HERE, on purpose: no clash detection, no `OST_SpecialtyEquipment`, no support auto-detection. Stage 1 is an input CONFIRMATION form and the user TYPES the supports (the owner ruled auto support detection is a later convenience). Reading geometry/materials off the live framing element is the button's job -- it hands the numbers it managed to read to `prefill()` and this module never touches a `doc`. """ import re SCHEMA = 'panelanalysis/1' # Eriksson's five INPUT section names, in the order the form stacks them. # Verbatim, per the owner's instruction. GROUPS live inside each section (see # `sections()`) and are new; the five names themselves are not renegotiable. SECTION_ORDER = ( 'Design Criteria', 'Concrete Extents', 'Structural Model', 'Handling', 'Reinforcement', ) # Nominal prestressing-strand section areas by diameter, in^2. Turns a strand # COUNT + SIZE the user types into the per-strand `area_in2` the deck carries. # Keyed by both the decimal and the fraction the size is written as. A size not # in this table is refused by name rather than guessed -- an invented area is a # fabricated capacity, the cardinal sin here. # '1/2 SP' is the 1/2" SPECIAL strand -- nominally the same 0.5" diameter as # plain 1/2" but a larger 0.167 in² section (owner, 2026-09-07, issue #23: # "the most-used size"), so it is its own dropdown choice, never a spelling # of '1/2'. Keyed here by the shape `strand_area_in2` normalises to # (lower-case, quotes and spaces stripped: '1/2 SP' -> '1/2sp'); the older # bare-decimal '0.52' key stays because stored values may carry it. _STRAND_AREA_IN2 = { '0.375': 0.085, '3/8': 0.085, '0.4375': 0.115, '7/16': 0.115, '0.5': 0.153, '1/2': 0.153, '0.52': 0.167, '1/2sp': 0.167, '0.5625': 0.192, '9/16': 0.192, '0.6': 0.217, '3/5': 0.217, '0.7': 0.294, } # Nominal strand DIAMETER, in inches, per spelling the dropdown offers or a # model read supplies -- what `prestress['diameter_in']` (transfer / # development length, `panellift.pci.development_length_in`) is built # from. Before 2026-09-07 that value came from `float()` of the typed text, # which fails on every fraction spelling ('3/8', '1/2', '9/16') and fell # back to 0.5 silently, so a 3/8" strand developed as a 1/2" one. '1/2 SP' # is a 1/2" strand by diameter (its extra is section, not size). _STRAND_DIA_NOMINAL_IN = { '0.375': 0.375, '3/8': 0.375, '0.4375': 0.4375, '7/16': 0.4375, '0.5': 0.5, '1/2': 0.5, '0.52': 0.5, '1/2sp': 0.5, '0.5625': 0.5625, '9/16': 0.5625, '0.6': 0.6, '3/5': 0.6, '0.7': 0.7, } # The one dropdown vocabulary for a strand size, shared by the scalar # `ps_dia_in` field (`FIELD_CHOICES`) and the `strand_row` table's own # `dia_in` column (`pa_reinforcement._STRAND_COLUMNS` reads this) -- one # list, so the two can never offer different sizes. STRAND_DIA_CHOICES = ('3/8', '7/16', '1/2', '1/2 SP', '9/16', '0.6', '0.7') DEFAULT_LOSS_PCT = 8.0 DEFAULT_FPU_KSI = 270.0 DEFAULT_SELF_WEIGHT_FACTOR = 1.0 DEFAULT_TENSION_LIMIT_SQRT_FC = 5.0 DEFAULT_COMPRESSION_LIMIT_RATIO = 0.6 DEFAULT_PHI_FLEXURE = 0.90 DEFAULT_PHI_SHEAR = 0.75 DEFAULT_DEV_MULT = 1.0 def concrete_modulus_psi(fc_psi, unit_weight_pcf): """ACI 318 concrete modulus of elasticity, in psi: Ec = 33 * wc^1.5 * sqrt(f'c) (wc in pcf, f'c in psi) the same normal-weight formula Eriksson's own printout uses (checked against a golden run: wc=150, f'c=6000 -> 4,695,900 psi = 4696 ksi; f'ci=3500 -> 3,586,500 psi = 3587 ksi, both matching the sample to the ksi). Returns None -- the could-not-compute answer, never a guessed zero -- when either input is missing, non-numeric, or not positive. Callers round to psi for display; Eci is this same function on f'ci. """ try: fc = float(fc_psi) wc = float(unit_weight_pcf) except (TypeError, ValueError): return None if fc <= 0 or wc <= 0: return None return 33.0 * (wc ** 1.5) * (fc ** 0.5) # Fields COMPUTED from other fields rather than typed or read: key -> # ((source_key, ...), compute_fn). The initial value is computed in # `prefill`; the WPF renderer (script.py) re-runs `derived_value` live as a # source field changes, one-directionally (source -> derived) and only until # the user edits the derived field itself, after which their value stands (the # owner's rule: editing f'c updates Ec, but editing Ec never rewrites f'c and # freezes Ec's auto-update). Keep this the single source of truth for the # derived relationships so a new one is added in one place, host-testable. DERIVED_FIELDS = { 'conc_ec_psi': (('fc_psi', 'unit_weight_pcf'), concrete_modulus_psi), 'conc_eci_psi': (('fci_psi', 'unit_weight_pcf'), concrete_modulus_psi), } def derived_value(key, values): """The computed value of a derived field from the current form `values`, or None when any source is blank/unreadable (so the caller shows blank, never a fabricated number). Rounding to a display string is the caller's job (`_fmt(round(v))`).""" spec = DERIVED_FIELDS.get(key) if not spec: return None sources, fn = spec args = [] for s in sources: v, _err = _parse_num(values.get(s) if values else None) if v is None: return None args.append(v) try: return fn(*args) except Exception: return None def derived_dependents(source_key): """The derived-field keys that read `source_key`, in `DERIVED_FIELDS` order. The renderer's live wiring asks this on every edit: "what, if anything, follows from the box that just changed?". Keeping the answer here rather than as a reversed table in the WPF file means a new derived relationship is declared once, in `DERIVED_FIELDS`, and the live update follows for free -- and it is host-testable, which the WPF side is not. Returns a TUPLE, empty for a key nothing derives from (the ordinary case -- most fields drive nothing). """ if not source_key: return () out = [] for key in sorted(DERIVED_FIELDS): sources, _fn = DERIVED_FIELDS[key] if source_key in sources: out.append(key) return tuple(out) def format_number(value): """A computed number as the string the form shows. None -> blank. The public name for `_fmt`: the renderer writes derived values back into live editors and needs exactly the formatting `prefill` used, or a field would visibly change shape ('4696' -> '4696.0') the moment it recomputed. """ return _fmt(value) # Constant, job-independent DEFAULTS the owner set for a fresh panel (2026-09-03): # normal-weight concrete, rebar 60/29000, mesh 80/29000, standard 270 ksi / # 28500 ksi strand. These are generic precast policy, not one job's data, and a # real value read off the model always wins -- `prefill` only applies a default # where the model supplied nothing. None triggers the prestress group # (ps_fpu_ksi/strand_ep_ksi are reference/extra keys, not the prestress # trigger), so defaulting them cannot make an un-prestressed panel fail to run. # The Prestress Losses seeds (owner, 2026-09-03): the PCI method is the # dropdown option string exactly as FIELD_CHOICES lists it, and 70% relative # humidity / 8 days to release / 14 days to erection are the PCI handbook's # usual assumptions for the loss estimate that follows. PREFILL_DEFAULTS = { 'concrete_type': 'Normalweight', 'rebar_fy_ksi': '60', 'rebar_es_ksi': '29000', 'mesh_fy_ksi': '80', 'mesh_es_ksi': '29000', 'ps_fpu_ksi': '270', 'strand_ep_ksi': '28500', 'ps_loss_method': 'PCI', 'ps_loss_humidity_pct': '70', 'ps_loss_release_hours': '8', 'ps_loss_erection_days': '14', # Design Criteria / Stress Limits & Factors -- the generic code values; # a plant's own numbers arrive through `settings_from_plant` first and # win (owner, 2026-09-03: "settings 里面也要做预选预先填充"). 'handling_tension_sqrt': '5', 'handling_comp_ratio': '0.6', 'inplace_tension_sqrt': '7.5', 'inplace_comp_ratio': '0.6', 'phi_flexure': '0.9', 'phi_shear': '0.75', # dev_mult 1.3 and second_order 'Yes' -- owner ruling, 2026-09-04: 1.3 is # Eriksson's own strand-normal development multiplier (ACI 318's # development length for strand), and P-Delta / second-order effects are # on by default; the plant settings path (`settings_from_plant`) still # overrides both where a plant has pinned its own value. 'dev_mult': '1.3', 'second_order': 'Yes', 'cracking_assumption': 'Uncracked', } # The Stress Limits & Factors fields a plant may pin in its knowledge layer # under `panelanalysis.settings.` (same key names as the form). SETTINGS_KEYS = ('handling_tension_sqrt', 'handling_comp_ratio', 'inplace_tension_sqrt', 'inplace_comp_ratio', 'phi_flexure', 'phi_shear', 'dev_mult', 'deflection_limit', 'second_order', 'cracking_assumption') def _yes_no(value): text = str(value).strip().lower() if text in ('', 'no', 'none', 'false', '0', 'off'): return 'No' return 'Yes' # 'yes', 'P-Delta', 'true', 'moment magnification'... def settings_from_plant(settings): """String form values from a plant's `panelanalysis.settings` mapping. `settings` is the mapping read off the knowledge layers (or None). Numbers come back as their shortest string ('0.9', '6', 'L/150' as is); `second_order` normalises to the form's Yes / No (a plant writes the METHOD there -- 'P-Delta' -- and any method means Yes); the cracking assumption normalises to Cracked / Uncracked. A key the plant does not carry, or carries as None / blank, is left out so `PREFILL_DEFAULTS` fills it. Never raises. """ out = {} if not isinstance(settings, dict): return out for key in SETTINGS_KEYS: value = settings.get(key) if value is None: continue text = str(value).strip() if not text: continue if key == 'second_order': out[key] = _yes_no(text) elif key == 'cracking_assumption': out[key] = 'Cracked' if text.lower().startswith('crack') else 'Uncracked' elif isinstance(value, float) and value == int(value): out[key] = str(int(value)) else: out[key] = text return out # The ACI 318 load combinations included by default (owner, 2026-09-03): # strength 1, 2, 5, 7 and service 1, 2, 5, 9. The combination tables live in # the Design Criteria section (pa_criteria) with one Include tick per row keyed # `load_combo...include`; `prefill` pre-ticks exactly this set and # leaves the rest for the user. The user may tick/untick any of them and edit # the factors -- this is the starting selection, never an enforced one. LOAD_COMBO_DEFAULT_INCLUDED = { 'strength': (1, 2, 5, 7), 'service': (1, 2, 5, 9), } def load_combo_include_key(name, n): """The values-dict key for one Include tick, e.g. 'load_combo.strength.1.include' -- mirrors pa_criteria's own key format.""" return 'load_combo.{0}.{1}.include'.format(name, n) # The load-combination FACTOR columns, in the order the table prints them: # dead, live, roof live, wind, seismic, temperature/restraint, lateral earth. LOAD_COMBO_FACTORS = ('D', 'L', 'R', 'W', 'E', 'T', 'H') # The standard ACI 318 basic strength and service combinations -- PUBLIC CODE # TEXT, the same rows any ACI-compliant tool prints, carrying no job data. # Each row is (id, D, L, R, W, E, T, H), verbatim from where they used to live # in `pa_criteria._STRENGTH_COMBOS` / `_SERVICE_COMBOS`. They moved here because # the owner asked for the factors to be EDITABLE (2026-09-03): the moment a # factor is a form value rather than printed text, the table stops being a # renderer detail and becomes part of the schema -- one place that knows both # the code default and the key it is stored under, host-testable, with the # section module reading it rather than owning a second copy. LOAD_COMBO_ROWS = { 'strength': ( ('1', '1.4', '0', '0', '0', '0', '1', '1.6'), ('2', '1.2', '1.6', '0.5', '0', '0', '1', '1.6'), ('3', '1.2', '1', '1.6', '0', '0', '1', '1.6'), ('4', '1.2', '0', '1.6', '0.5', '0', '1', '1.6'), ('5', '1.2', '1', '0.5', '1', '0', '1', '1.6'), ('6', '1.2', '1', '0.2', '0', '1', '1', '1.6'), ('7', '0.9', '0', '0', '1', '0', '1', '1.6'), ('8', '0.9', '0', '0', '0', '1', '1', '1.6'), ), 'service': ( ('1', '1', '0', '0', '0', '0', '0.75', '1'), ('2', '1', '1', '0', '0', '0', '0.75', '1'), ('3', '1', '0', '1', '0', '0', '0.75', '1'), ('4', '1', '0.75', '0.75', '0', '0', '0.75', '1'), ('5', '1', '0', '0', '0.6', '0', '0.75', '1'), ('6', '1', '0', '0', '0', '0.7', '0.75', '1'), ('7', '1', '0.75', '0.75', '0.45', '0', '0.75', '1'), ('8', '1', '0.75', '0.75', '0', '0.525', '0.75', '1'), ('9', '0.6', '0', '0', '0.6', '0', '0.75', '1'), ('10', '0.6', '0', '0', '0', '0.7', '0.75', '1'), ), } def load_combo_factor_key(name, n, col): """The values-dict key for one editable factor cell, e.g. 'load_combo.strength.1.D' -- the same `load_combo...` shape the Include tick uses, so one row's cells all sit together.""" return 'load_combo.{0}.{1}.{2}'.format(name, n, col) def load_combo_row_ids(name, values): """Every row id in one table: the code rows first, then the user's own. A user row is discovered from the values dict, not from a counter kept somewhere else -- the form adds a row by writing its keys, so the keys ARE the record of what rows exist, and a row survives anything that rebuilds the table from `values`. User ids are 'u1', 'u2', ... and are returned in numeric order after every code row, so a table never renumbers under the user (the row they typed into keeps its id). """ ids = [row[0] for row in LOAD_COMBO_ROWS.get(name, ())] prefix = 'load_combo.{0}.u'.format(name) extra = [] for key in (values or {}): if not key.startswith(prefix) or not key.endswith('.include'): continue rid = key[len('load_combo.{0}.'.format(name)):-len('.include')] if '.' in rid or rid in extra: continue try: int(rid[1:]) except (TypeError, ValueError): continue extra.append(rid) extra.sort(key=lambda r: int(r[1:])) return ids + extra def load_combinations(values): """The TICKED load combinations, as [{table, id, factors, included}]. One entry per row whose Include tick is on, in table then row order. Each `factors` maps a column letter to the parsed number; a cell that will not parse comes back `None` with a sentence in that entry's `problem` -- never a silent 0, which is a real factor and would read as "this load is deliberately excluded" rather than "nobody could read what you typed". A blank cell is also None, with its own reason: the user cleared it, and an empty factor is not a zero factor. """ values = values or {} out = [] for name in ('strength', 'service'): for rid in load_combo_row_ids(name, values): tick, _err = _parse_bool(values.get(load_combo_include_key( name, rid))) if tick is not True: continue factors = {} problems = [] for col in LOAD_COMBO_FACTORS: raw = values.get(load_combo_factor_key(name, rid, col)) val, err = _parse_num(raw) factors[col] = val if err: problems.append( '{0} combination {1}: factor {2} {3}'.format( name, rid, col, err)) elif val is None: problems.append( '{0} combination {1}: factor {2} is blank -- a blank ' 'factor is not a zero factor'.format(name, rid, col)) entry = {'table': name, 'id': rid, 'factors': factors, 'included': True} if problems: entry['problem'] = '; '.join(problems) out.append(entry) return out # ------------------------------------------------------------- row tables # # A second row-table mechanism, generalising `load_combo_row_ids` / # `load_combo_factor_key` above for the tables the owner ruled must be typed, # EDITABLE grids rather than a free-text box or a locked-down summary # (2026-09-04: "只是让你预先输入,不是让你直接锁死啊" for restraints; wind # pressure/suction and where it applies typed directly instead of an ASCE # input set; a concentrated-load editor was simply missing; "rebar 怎么可能 # 是打字上去?" for the two reinforcement tables). One string value per cell, # keyed `
..` (n = 1-based, ASCENDING, never reused once a row # has ever existed -- see `_row_ids_any`), plus a per-row presence flag under # the `present` column ('yes' / anything else). A row is never actually # deleted from `values` by `remove_row`, only marked absent, so removing a # row and adding a new one never collides with an id an in-flight edit or an # undo still refers to. This is deliberately the SAME shape # `load_combo_row_ids` / `load_combo_factor_key` already use for the # strength/service tables (`load_combo.
..`) -- generalised to # a bare `
` here so a page module can build a new row-editable table # without reinventing the key format. # # The five tables built ON this mechanism today, each `(name, cols)`: # # restraint RESTRAINT_COLS in-place lateral restraints # conc_load CONC_LOAD_COLS concentrated (bearing) loads # wind_zone WIND_ZONE_COLS manually-typed wind pressure/suction zones # strand_row STRAND_ROW_COLS the prestressing strand schedule # mild_bar MILD_BAR_COLS mild (non-prestressed) reinforcement def row_key(table, n, col): """The `values` dict key for one row-table cell, e.g. 'restraint.3.elevation_ft' -- the same '
..' shape `load_combo_factor_key` uses for its own (sub-namespaced) tables.""" return '{0}.{1}.{2}'.format(table, n, col) def _row_ids_any(table, values): """Every row id `table` has EVER carried a key for in `values`, present or not. `add_row` uses this (never `row_ids`) to pick a new row's id, so a removed row's id is never handed to a different row -- ids only ever go up, the same discipline `load_combo_row_ids`'s user rows ('u1', 'u2', ...) already follow.""" values = values or {} prefix = table + '.' ids = set() for key in values: if not key.startswith(prefix): continue n_txt = key[len(prefix):].split('.', 1)[0] try: n = int(n_txt) except (TypeError, ValueError): continue if n > 0: ids.add(n) return ids def row_ids(table, values): """Every PRESENT row id in `table`, ascending. A row counts as present when its `present` cell reads 'yes' (case-insensitive, matching `_parse_bool`'s vocabulary loosely); a row whose `present` cell is missing, blank, or anything else ('no', a removed row) is not returned. Always ascending, never insertion order, so a caller never has to sort this again. """ values = values or {} out = [n for n in _row_ids_any(table, values) if (values.get(row_key(table, n, 'present')) or '').strip() .lower() == 'yes'] out.sort() return out def rows_of(table, values, cols): """[{'n': n, col: value, ...}, ...] -- one dict per PRESENT row, ascending `n`, every `col` read as its raw string ('' when the cell was never written). `n` (an int) rides along on every row so a validation message can name 'row 3' without the caller re-deriving it from the key it came from.""" values = values or {} out = [] for n in row_ids(table, values): row = {'n': n} for col in cols: row[col] = values.get(row_key(table, n, col)) or '' out.append(row) return out def add_row(table, values, cols, defaults=None): """Writes a new row's `present` flag and every column's starting text into `values` IN PLACE and returns its `n`. The new id is one past the HIGHEST id `table` has ever used, present or removed (`_row_ids_any`) -- so removing row 2 and adding a new row never reuses '2' for a different row. `defaults` maps a subset of `cols` to their starting text ('' for anything not named). A `values` that is not a (mutable) mapping writes nothing and returns None -- the same could-not-act discipline `apply_defaults` uses for a malformed `values`. """ try: existing = _row_ids_any(table, values) n = (max(existing) + 1) if existing else 1 values[row_key(table, n, 'present')] = 'yes' defaults = defaults or {} for col in cols: values[row_key(table, n, col)] = defaults.get(col) or '' return n except (TypeError, AttributeError): return None def remove_row(table, values, n): """Marks row `n` absent (`present` -> 'no') IN PLACE. The cell values are left sitting in `values`, harmless and unread -- `row_ids` / `rows_of` never return an absent row, and `n` is never reused by `add_row` regardless (see `_row_ids_any`). A `values` that is not a mutable mapping is a no-op.""" try: values[row_key(table, n, 'present')] = 'no' except (TypeError, AttributeError): pass # Table name + column-order constants for the five tables above. Column # order matches the ROW-TABLE VALUE CONTRACT exactly (the same order a # renderer should draw them in). `_n` NEVER appears in a `cols` tuple -- it # is not a cell, it rides along on every `rows_of` entry automatically. RESTRAINT_TABLE = 'restraint' RESTRAINT_COLS = ('elevation_ft', 'type', 'gravity', 'thermal', 'stiffness_kin') # Eriksson's own restraint-type vocabulary, plus 'Floor tie' (the owner's # addition) -- offered, never enforced (the box stays free text/editable). RESTRAINT_TYPE_CHOICES = ('Pin', 'Fixed', 'Free', 'Floor tie') CONC_LOAD_TABLE = 'conc_load' CONC_LOAD_COLS = ('label', 'elevation_ft', 'offset_in', 'dead_k', 'live_k') WIND_ZONE_TABLE = 'wind_zone' WIND_ZONE_COLS = ('label', 'from_elev_ft', 'to_elev_ft', 'width_ft', 'pressure_psf', 'suction_psf') WIND_MODE_CHOICES = ('Manual', 'From project config') STRAND_ROW_TABLE = 'strand_row' STRAND_ROW_COLS = ('count', 'depth_in', 'dia_in', 'pull_lb') MILD_BAR_TABLE = 'mild_bar' MILD_BAR_COLS = ('size', 'count', 'depth_in', 'direction', 'from_ft', 'to_ft', 'spacing_in') MILD_BAR_DIRECTION_CHOICES = ('Longitudinal', 'Transverse') # ASTM standard rebar area per bar, in^2 (#3 through #11) -- public code # data, not a plant standard. `MILD_BAR_SIZE_CHOICES` is this table's own # keys, in size order, for a renderer's dropdown. MILD_BAR_AREA_IN2 = { '#3': 0.11, '#4': 0.20, '#5': 0.31, '#6': 0.44, '#7': 0.60, '#8': 0.79, '#9': 1.00, '#10': 1.27, '#11': 1.56, } MILD_BAR_SIZE_CHOICES = ('#3', '#4', '#5', '#6', '#7', '#8', '#9', '#10', '#11') # Owner ruling, 2026-09-05: bearing/support positions are a row table like # every other structural input, not a comma-separated text box -- one row # per position along the panel's length, add/remove the same way restraints # do. `SUPPORT_COLS` is deliberately one column: a position has nothing # else to carry (no type, no stiffness -- those live on the RESTRAINT table # for the elevations that actually tie the panel to something; a support # row here only says where THIS analysis treats the panel as held up). SUPPORT_TABLE = 'support' SUPPORT_COLS = ('x_ft',) # Owner ruling, 2026-09-05: openings/reveals/solid zones are a row table, # not a free-text field with an undiscoverable seven-field-per-line grammar. # Columns are ABSOLUTE positions/extents along the panel's own two axes # (x_ft/length_ft along the length, always measured from the panel's start; # y_ft/width_ft across the width, always measured from the bottom) -- the # same simplification the `restraint` table already made for elevations # (typed directly, no `_from` word to interpret). `depth_in` is meaningful # only for a Reveal (how far it cuts into the panel's thickness); left blank # for an Opening (always full depth) or a Solid Zone (no `panellift` shape # either way, see `_openings_for_deck`). This REPLACES the old # `{pos_along, pos_along_from, length, pos_cross, pos_cross_from, width, # pos_vert, height}` shape everywhere in this module -- `openings_rows` (the # `PASSTHROUGH_KEYS` entry, `panelsketch`'s own contract, and # `_openings_for_deck`'s input) is now built FROM this table's rows by # `_opening_table_rows`, not read as a separate structured list. OPENING_TABLE = 'opening' OPENING_COLS = ('type', 'x_ft', 'length_ft', 'y_ft', 'width_ft', 'depth_in') OPENING_TYPE_CHOICES = ('Opening', 'Reveal', 'Solid zone') # Owner ruling, 2026-09-07 (issue #23, item 9h): an INTERRUPT is a position # along the panel's length where reinforcement is cut and development # restarts -- a break or a saw cut, independent of any opening (which # `panellift.prestress.cuts_for` already handles for strands). One row per # interrupt: `x_ft` along the length from the panel's start, the same axis # and datum `OPENING_COLS`' `x_ft` and `MILD_BAR_COLS`' `from_ft` use, and # `cuts`, which reinforcement it severs there. Collected here, on the # page, only; the development-length restart is the companion engine # issue's job and nothing reads this table into the deck yet. INTERRUPT_TABLE = 'interrupt' INTERRUPT_COLS = ('x_ft', 'cuts') INTERRUPT_CUTS_CHOICES = ('All', 'Strands', 'Rebar', 'Mesh') # Owner ruling, 2026-09-05: `face_lifters` / `edge_lifters` free text # ("3.50 @ 0.18", one lifter per line) becomes two editable row tables. # `face_lifter` is an absolute position pair (x_ft along the length, y_ft # across the width) -- the same "typed directly, no fraction to interpret" # simplification the `opening`/`restraint` tables already made. `edge_lifter` # NAMES the edge directly (Top/Bottom = the panel's two long sides, measured # along the length; Left/Right = its two ends, measured across the width) -- # see `vibe.pa_sketch_handling._edge_lifter_rows_from_table`'s own comment # for the mapping onto that module's existing kind/side vocabulary. Both # tables are read by `vibe.pa_sketch_handling` (the drawing) and # `vibe.pa_handling` (the summary table) in preference to the legacy # `face_lifters`/`edge_lifters` text, which stays a `FIELDS` entry read only # as a fallback for a panel saved before these tables existed. FACE_LIFTER_TABLE = 'face_lifter' FACE_LIFTER_COLS = ('x_ft', 'y_ft') EDGE_LIFTER_TABLE = 'edge_lifter' EDGE_LIFTER_COLS = ('edge', 'position_ft') EDGE_LIFTER_EDGE_CHOICES = ('Top', 'Bottom', 'Left', 'Right') # Which ROW TABLE replaced each legacy free-text field (issue #22, item 11). # `validate()` still reports a problem under the legacy key when it falls # back to it -- `supports_ft` ("at least two support positions are needed") # is the standing case -- but that key is no longer a rendered field, so a # page looking the problem up by key found nothing to highlight and the # refusal sank into the banner's general bucket with no pointer. The page # reads this map (serialised as `problem_targets` by `pa_pagespec`) to # route such a problem onto the table that actually holds the answer now. # Keep this beside the table constants: a new legacy/table split is added # here or the page cannot point at it. LEGACY_TABLE_KEYS = { 'supports_ft': SUPPORT_TABLE, 'supports': SUPPORT_TABLE, 'openings': OPENING_TABLE, 'openings_rows': OPENING_TABLE, 'face_lifters': FACE_LIFTER_TABLE, 'edge_lifters': EDGE_LIFTER_TABLE, } def _F(key, label, symbol, help, required, kind, section, group, drives): """One field record, as a dict -- the unit `FIELDS` is built from. A dict, not a namedtuple: this record grew from a 6-tuple to 9 named parts over two rulings (Eriksson parity, then plain-language labels), and a positional tuple that wide is unreadable at every call site that builds or reads one. Every reader below goes through `_spec` / `display_label` / `help_of` / `symbol_of`, never a raw index, so the dict shape is the only place the field's parts are named. Keys: 'key' (the values-dict / deck key), 'label' (plain English, no engineering notation), 'symbol' (the engineering notation + unit, shown BESIDE the label, never replacing it -- '' when there is none), 'help' (one sentence, plain words, says what happens if left blank), 'required' (bool -- the Run-button gate), 'kind' ('text' | 'num' | 'int' | 'supports' | 'openings' | 'strands' -- what parses it), 'section' (one of SECTION_ORDER), 'group' (a name inside the section, or None for the section's own ungrouped/primary fields), 'drives' ('solve' | 'stage3' | 'info', as above). """ return { 'key': key, 'label': label, 'symbol': symbol, 'help': help, 'required': required, 'kind': kind, 'section': section, 'group': group, 'drives': drives, } # The form fields, in display order within their section/group. See `_F`'s # docstring for what each part means. Kept in Eriksson's own page order inside # each section so a reviewer can tell it was copied, not reinvented. FIELDS = [ # ============================================================= # Design Criteria / Project Info (Eriksson Project Info p.1 header) # ============================================================= _F('project_name', 'Project name', '', "The project this panel belongs to. Pre-filled from the model's " 'Project Information (Project Name). Reference only.', False, 'text', 'Design Criteria', 'Project Info', 'info'), _F('client_name', 'Client', '', "The client on the model's Project Information (Client Name). " 'Reference only.', False, 'text', 'Design Criteria', 'Project Info', 'info'), _F('design_date', 'Design date', '', 'The date this analysis is prepared; defaults to today. Reference ' 'only.', False, 'text', 'Design Criteria', 'Project Info', 'info'), _F('job_number', 'Job number', '', "This job's tracking number, read from the Design Summary sheet's " 'title block where the model carries it. Reference only.', False, 'text', 'Design Criteria', 'Project Info', 'info'), # ============================================================= # Design Criteria / Material Properties (Eriksson Project Info p.1) # ============================================================= _F('concrete_type', 'Concrete type', '', 'What kind of concrete this panel is cast from (normal weight, ' 'lightweight, ...). Reference only -- nothing computes from it yet.', False, 'text', 'Design Criteria', 'Material Properties', 'info'), _F('fc_psi', 'Concrete strength at 28 days', u"f′c (psi)", "The concrete's design strength once it has fully cured; every stress " 'limit on this form is a multiple of this number. Required -- the ' 'solve cannot run without it.', True, 'num', 'Design Criteria', 'Material Properties', 'solve'), _F('fci_psi', 'Concrete strength at release', u"f′ci (psi)", 'How strong the concrete needs to be when the strand is cut or the ' 'panel is stripped from the bed -- usually lower than the 28-day ' 'strength. Leave blank if this panel is not prestressed.', False, 'num', 'Design Criteria', 'Material Properties', 'solve'), _F('unit_weight_pcf', 'Concrete unit weight', 'pcf', 'How much the concrete weighs per cubic foot; the self-weight of the ' 'panel is computed from this. Required.', True, 'num', 'Design Criteria', 'Material Properties', 'solve'), # The SECOND wythe's material properties, for a Sandwich section only. # The wythe-1 fields above (fc_psi / fci_psi / unit_weight_pcf) stay the # plain, un-suffixed names -- they describe wythe 1 (the "main" concrete), # and the pair is what makes a sandwich's per-wythe Concrete Properties # answerable. These three are NOT required and do NOT drive the solve yet: # collected so the form is honest that a sandwich has two concretes, and # shown (by the renderer) only while section_type is Sandwich. _F('fc2_psi', 'Concrete strength at 28 days, wythe 2', u"f′c₂ (psi)", 'The 28-day design strength of the SECOND concrete wythe, for a ' 'Sandwich section only -- normally the same concrete as wythe 1 ' 'unless the panel is a mix. Pre-filled from wythe 1 as a starting ' 'assumption. Not yet used by the solve.', False, 'num', 'Design Criteria', 'Material Properties', 'stage3'), _F('fci2_psi', 'Concrete strength at release, wythe 2', u"f′ci₂ (psi)", 'The release strength of the SECOND concrete wythe, for a Sandwich ' 'section only. Pre-filled from wythe 1 as a starting assumption. ' 'Leave blank if the panel is not prestressed or the wythes are the ' 'same concrete. Not yet used by the solve.', False, 'num', 'Design Criteria', 'Material Properties', 'stage3'), _F('unit_weight2_pcf', 'Concrete unit weight, wythe 2', 'pcf', 'How much the SECOND concrete wythe weighs per cubic foot, for a ' 'Sandwich section only (e.g. a lightweight architectural wythe on a ' 'normal-weight structural one). Pre-filled from wythe 1 as a ' 'starting assumption. Not yet used by the solve.', False, 'num', 'Design Criteria', 'Material Properties', 'stage3'), _F('conc_eci_psi', 'Concrete elastic modulus at release', u"E′ci (psi)", "How stiff the concrete is at release strength. Computed by ACI 318 " "from the release strength and unit weight (Ec = 33 wc^1.5 sqrt(f'ci)) " 'and kept up to date as you edit those; type a value to override it.', False, 'num', 'Design Criteria', 'Material Properties', 'info'), _F('conc_ec_psi', 'Concrete elastic modulus at 28 days', u"E′c (psi)", "How stiff the concrete is once fully cured. Computed by ACI 318 from " "the 28-day strength and unit weight (Ec = 33 wc^1.5 sqrt(f'c)) and " 'kept up to date as you edit those; type a value to override it.', False, 'num', 'Design Criteria', 'Material Properties', 'info'), _F('rebar_fy_ksi', 'Mild rebar yield strength', 'fy (ksi)', 'The stress at which the ordinary (non-prestressed) reinforcing bar ' 'starts to yield. Shown for the record; not yet used by the solve.', False, 'num', 'Design Criteria', 'Material Properties', 'stage3'), _F('rebar_es_ksi', 'Mild rebar modulus of elasticity', 'Es (ksi)', 'How stiff the ordinary reinforcing steel is. Shown for the record; ' 'not yet used by the solve.', False, 'num', 'Design Criteria', 'Material Properties', 'stage3'), _F('mesh_fy_ksi', 'Mesh yield strength', 'fy (ksi)', 'The yield strength of the welded wire mesh. Defaults to the common ' '80 ksi. Shown for the record; not yet used by the solve.', False, 'num', 'Design Criteria', 'Material Properties', 'stage3'), _F('mesh_es_ksi', 'Mesh modulus of elasticity', 'Es (ksi)', 'How stiff the welded wire mesh is. Defaults to 29000 ksi. Shown for ' 'the record; not yet used by the solve.', False, 'num', 'Design Criteria', 'Material Properties', 'stage3'), _F('strand_ep_ksi', 'Strand modulus of elasticity', 'Ep (ksi)', 'How stiff the prestressing strand is. Shown for the record; not yet ' 'used by the solve.', False, 'num', 'Design Criteria', 'Material Properties', 'stage3'), _F('ps_fpu_ksi', 'Strand ultimate strength', u'fₚᵤ (ksi)', 'The breaking strength of the prestressing strand; used with the ' 'per-group pull percentage below to work out the jacking force. ' 'Leave blank if this panel is not prestressed.', False, 'num', 'Design Criteria', 'Material Properties', 'solve'), # ============================================================= # Design Criteria / Prestress Losses (Eriksson Project Info p.1) # ============================================================= _F('ps_loss_method', 'Prestress loss method', '', 'Which published method (PCI, AASHTO, ...) the assumed prestress ' 'loss below comes from. Reference only -- the solve uses the loss ' 'percentage you type in Reinforcement directly, not this method.', False, 'text', 'Design Criteria', 'Prestress Losses', 'stage3'), _F('ps_loss_humidity_pct', 'Ambient humidity assumed', '%', 'The relative humidity the loss method above uses -- losses are ' 'larger in drier air. Not yet used by the solve.', False, 'num', 'Design Criteria', 'Prestress Losses', 'stage3'), _F('ps_loss_release_hours', 'Hours from casting to release', '', 'How many hours after casting the strand is cut / the panel is ' 'stripped. Not yet used by the solve.', False, 'num', 'Design Criteria', 'Prestress Losses', 'stage3'), _F('ps_loss_erection_days', 'Days from casting to erection', '', 'How many days after casting the panel is erected in place. Not yet ' 'used by the solve.', False, 'num', 'Design Criteria', 'Prestress Losses', 'stage3'), # ============================================================= # Design Criteria / Load Combinations (Eriksson Project Info p.1) # ============================================================= _F('load_combinations', 'Load combinations checked', '', 'Which ACI 318 strength and service load combinations apply to this ' 'panel, in your own words. Not yet used by the solve -- the in-place ' 'engine builds these itself later.', False, 'text', 'Design Criteria', 'Load Combinations', 'stage3'), # ============================================================= # Design Criteria / Stress Limits & Factors (Eriksson Settings p.2) # ============================================================= _F('handling_tension_sqrt', 'Tension allowed while handling', u"n·√f′ci", 'How much the concrete may be pulled apart (tension) during ' 'stripping / handling, as a multiple of √f′ci. Blank uses the ' 'built-in default.', False, 'num', 'Design Criteria', 'Stress Limits & Factors', 'solve'), _F('handling_comp_ratio', 'Compression allowed while handling', u"n·f′ci", 'How much the concrete may be squeezed (compression) during ' "handling, as a fraction of f′ci. Blank uses the built-in default.", False, 'num', 'Design Criteria', 'Stress Limits & Factors', 'solve'), _F('phi_flexure', 'Bending strength reduction factor', u'φ, flexure', "The ACI safety factor applied to the panel's computed bending " 'strength. Blank uses the built-in default (0.90).', False, 'num', 'Design Criteria', 'Stress Limits & Factors', 'solve'), _F('phi_shear', 'Shear strength reduction factor', u'φ, shear', "The ACI safety factor applied to the panel's computed shear " 'strength. Blank uses the built-in default (0.75).', False, 'num', 'Design Criteria', 'Stress Limits & Factors', 'solve'), _F('dev_mult', 'Strand development length multiplier', '', 'A multiplier on the strand development length used near the ends ' 'of the panel. Blank uses the built-in default (1.0).', False, 'num', 'Design Criteria', 'Stress Limits & Factors', 'solve'), _F('inplace_tension_sqrt', 'Tension allowed once erected', u"n·√f′c", 'The same tension limit as above, but for the panel once it is ' 'standing in place rather than being handled. Not yet used by the ' 'solve.', False, 'num', 'Design Criteria', 'Stress Limits & Factors', 'stage3'), _F('inplace_comp_ratio', 'Compression allowed once erected', u"n·f′c", 'The same compression limit as above, but once the panel is ' 'standing in place. Not yet used by the solve.', False, 'num', 'Design Criteria', 'Stress Limits & Factors', 'stage3'), _F('deflection_limit', 'Deflection limit', 'L / n', 'How much the panel is allowed to bow or sag, as a fraction of its ' 'span. Read into the in-place service check (`_service_from`); ' 'blank leaves that check NOT JUDGED.', False, 'text', 'Design Criteria', 'Stress Limits & Factors', 'stage3'), _F('second_order', 'Second-order effects', '', 'Whether P-delta / second-order bending is considered for this ' 'panel. Not yet used by the solve.', False, 'text', 'Design Criteria', 'Stress Limits & Factors', 'stage3'), _F('cracking_assumption', 'Cracked or uncracked section assumed', '', 'Whether the stress check assumes the concrete section is cracked ' 'or uncracked. Read into the in-place service check as the ' 'cracking class (`_service_from`).', False, 'text', 'Design Criteria', 'Stress Limits & Factors', 'stage3'), # ============================================================= # Concrete Extents (Eriksson p.3; section properties are COMPUTED, not # input, so they have no field here) # ============================================================= _F('section_type', 'Section type', '', 'What the cross-section is: Solid or Sandwich (insulated). Choosing ' 'Sandwich reveals the per-wythe Concrete Extents fields and the ' 'second wythe\'s material properties. Reference only.', False, 'text', 'Concrete Extents', None, 'info'), # Top/bottom elevation: read off the framing element's own world-space # bounding box (`pa_elevation.elevations_from_bbox`, wired by the # button's `_read_elevations`) for a piece modelled standing up in # place. Declared here (owner ruling, 2026-09-05) so a value typed into # either one is actually saved -- `pa_extents._field` only prints its # "not a field yet" limitation note when `panelanalysisform` does not # know the key, and these two are read (with their own # `*_basis` sentence, carried through by `prefill`'s generic "every # other read lands too" pass) on every model-backed run. `drives='info'`: # `_bottom_elevation` / `_wind_from` / `_restraints_from` read the raw # string straight out of `values`, not this parse, so nothing here needs # to block the run over a value that will not parse as a number. _F('top_elevation_ft', 'Top elevation', 'ft', 'How high the top edge of the panel sits above the project ' 'reference elevation. Pre-filled from the model\'s own bounding box ' 'for a piece modelled standing up in place; leave blank if not yet ' 'known.', False, 'num', 'Concrete Extents', None, 'info'), _F('bottom_elevation_ft', 'Bottom elevation', 'ft', 'How high the bottom edge of the panel sits above the project ' 'reference elevation. Pre-filled the same way as Top elevation, ' 'and shared by the wind-zone and restraint elevation-to-position ' 'conversions. Leave blank if not yet known.', False, 'num', 'Concrete Extents', None, 'info'), _F('length_ft', 'Panel length', 'ft', "The panel's long dimension, end to end. Required -- everything " 'downstream is measured against this span.', True, 'num', 'Concrete Extents', None, 'solve'), _F('width_ft', 'Panel width', 'ft', "The panel's dimension across the span -- how wide it is. Required.", True, 'num', 'Concrete Extents', None, 'solve'), _F('thickness_in', 'Panel thickness', 'in', 'How thick the panel is. Required.', True, 'num', 'Concrete Extents', None, 'solve'), _F('top_in_form', 'Which face is up in the casting bed', 'Interior / Exterior', 'Which face of the panel faces up while it is cast. Reference only.', False, 'text', 'Concrete Extents', None, 'stage3'), _F('left_miter', 'Left edge mitered?', '', 'Whether the left edge of the panel is cut at a miter angle rather ' "than square. Leave blank if it is not -- Eriksson prints a sentence " "('Left miter not included in design') for that case rather than a " 'blank field; this form leaves it blank instead. Not yet used by ' 'the solve.', False, 'text', 'Concrete Extents', None, 'stage3'), _F('right_miter', 'Right edge mitered?', '', 'The same as the left-edge question above, for the right edge. Not ' 'yet used by the solve.', False, 'text', 'Concrete Extents', None, 'stage3'), # The top edge's own formed step -- separate from `top_in_form` (which # face is up in the bed). Boolean stored as 'yes'/'' the same way the # restraint table's `gravity` flag is, per this form's own convention; # declared here (owner ruling, 2026-09-05) so the width/height typed # beside it are actually saved. # Eriksson Wall's "top in form" STEP: a change in the formed thickness # at the top edge (a rebate / step in the section profile), which is a # different thing from `top_in_form` above (which face is up). Declared # so a saved value survives, but NOT RENDERED since issue #22 (item 4, # 2026-09-07): nothing in `pa_sketch_extents`, the weight/centroid # summary or `panellift` reads these three, and the owner's complaint # was a checkbox plus two number boxes that accepted input and changed # nothing ("我尝试打勾,填进去数字,也没啥反应啊"). They come back onto the # page the day `outline_geometry_ft` / `section_at` fold the step into # the drawn section, the weight and the centroid -- not before. _F('top_in_form_check', 'Top edge has a formed step', '', 'Whether the top edge of the panel has a formed step -- a change ' 'in the formed thickness at the top edge, not which face faces up. ' 'Leave unchecked if there is none. Not yet used by the solve, and ' 'not shown on the page until it is.', False, 'text', 'Concrete Extents', None, 'stage3'), _F('top_in_form_width_in', 'Formed step width', 'in', 'How wide the formed step at the top edge is, if there is one. ' 'Leave blank if there is no step. Not yet used by the solve, and ' 'not shown on the page until it is.', False, 'num', 'Concrete Extents', None, 'stage3'), _F('top_in_form_height_in', 'Formed step height', 'in', 'How tall the formed step at the top edge is, if there is one. ' 'Leave blank if there is no step. Not yet used by the solve, and ' 'not shown on the page until it is.', False, 'num', 'Concrete Extents', None, 'stage3'), _F('openings', 'Openings, reveals and solid zones', '', 'One row per opening, reveal or solid zone, one per line: ' "'type, pos_along, pos_along_from, length, pos_cross, " "pos_cross_from, width, pos_vert, height [, label]' -- e.g. " "'Opening, 2, start, 3, 4, left, 5, 1, 6, Door' is an opening 2 ft " "from the start of the panel, 3 ft long, 4 ft in from the left edge " "and 5 ft wide, 1 ft up from the bottom and 6 ft tall, labelled " "'Door'. type is Opening / Reveal / Solid Zone; the two 'from' " "words are start/end and left/right. Leave blank if there are none. " 'Not yet used by the solve (see the module docstring for why).', False, 'openings', 'Concrete Extents', 'Openings / Reveals', 'stage3'), # The three thicknesses of a sandwich (insulated) panel's cross-section, # read off the model's layup when the piece is detected as a Sandwich. # Each is in inches, displayed top wythe -> insulation -> bottom wythe. # Shown only while `section_type` is Sandwich -- the renderer collapses # the whole group otherwise. _F('wythe_top_in', 'Top wythe thickness', 'in', 'How thick the TOP concrete wythe of the sandwich is, as read ' 'through the piece\'s thickness (the outermost layer on the high ' 'side of the local thickness axis). Applies only when the Section ' 'type is Sandwich. Pre-filled from the model when it can be read; ' 'blank means "not read", never zero.', False, 'num', 'Concrete Extents', 'Sandwich Wythes', 'stage3'), _F('insulation_in', 'Insulation thickness', 'in', 'How thick the insulation core between the two concrete wythes is. ' 'Applies only when the Section type is Sandwich. Pre-filled from the ' 'model when it can be read; blank means "not read", never zero.', False, 'num', 'Concrete Extents', 'Sandwich Wythes', 'stage3'), _F('wythe_bottom_in', 'Bottom wythe thickness', 'in', 'How thick the BOTTOM concrete wythe of the sandwich is, as read ' 'through the piece\'s thickness (the outermost layer on the low ' 'side of the local thickness axis). Applies only when the Section ' 'type is Sandwich. Pre-filled from the model when it can be read; ' 'blank means "not read", never zero.', False, 'num', 'Concrete Extents', 'Sandwich Wythes', 'stage3'), _F('ext_soil_loads', 'Exterior soil loads', '', 'Any soil load pressing on the panel from outside (a below-grade or ' 'retaining condition). Leave blank if there are none. Not yet used ' 'by the solve.', False, 'text', 'Concrete Extents', 'Soil Loads', 'stage3'), _F('int_soil_loads', 'Interior soil loads', '', 'The same as the exterior soil question above, for the interior ' 'face. Not yet used by the solve.', False, 'text', 'Concrete Extents', 'Soil Loads', 'stage3'), # ============================================================= # Structural Model (Eriksson p.5) # ============================================================= # Owner ruling, 2026-09-05: support positions are now an EDITABLE ROW # TABLE (`SUPPORT_TABLE` / `SUPPORT_COLS`, drawn by `pa_structural.page` # as an `editable_table` block) prefilled off the same `supports_ft` # model read this field used to hold directly -- one row per position, # add/remove like every other row table. `supports_ft` itself STAYS a # `FIELDS` entry (its `display_label`/`help_of` are still used by the # "at least two supports" validation message) but is no longer rendered # as its own field; it is read only as a HIDDEN FALLBACK for a values # file saved before this table existed and carrying no `support.*` rows. _F('supports_ft', 'Support positions', 'ft, along the length', 'Where the panel is held up for THIS analysis, measured along its ' 'length; at least two. Required -- an unsupported panel cannot be ' 'checked. Superseded by the Support Positions table below; kept ' 'only so an older saved panel with no table rows still reads.', True, 'supports', 'Structural Model', None, 'solve'), # `restraints` and `conc_loads` (free-text boxes) are GONE -- owner ruling # 2026-09-04 ("只是让你预先输入,不是让你直接锁死啊" for restraints; a # concentrated-load editor was simply missing). Both are now ROW TABLES # (`RESTRAINT_TABLE` / `CONC_LOAD_TABLE`, via `row_key`/`row_ids`/ # `rows_of`/`add_row`/`remove_row`), not a `FIELDS` entry at all -- the # same way the Handling stage matrix is a grid rather than a field. See # `_restraints_from` / `_conc_loads_from`. # # Wind: same ruling, same day -- "既然要用户直接输入 wind pressure/ # suction,那就别要前面那些东西了。前面那些应该在 project-config 里面, # 从这里移除。添加一个按钮,按照 project-config 计算,还是用户手动输入。 # 现在就只做用户手动输入的部分。手动输入不仅仅是 windload,还有 apply # 在什么位置,从哪个 elevation 到哪个 elevation,多大的宽度。" Every ASCE # input (Vult, risk/exposure/enclosure, code version, C&C/MWFRS chapter, # mean roof height, effective wind area) and the old direct-entry # pressure/suction pair are GONE from this form -- ASCE site data belongs # to project config, a later "From project config" button this form # explicitly refuses to run today (see `validate`). What is left is # `wind_mode` (this one real FIELDS entry) plus the `wind_zone` ROW TABLE # (`WIND_ZONE_TABLE`) the user types pressure/suction and where it # applies into directly. See `_wind_from`. _F('wind_mode', 'Wind input mode', '', 'Whether wind pressure and suction are typed directly (Manual) or ' 'computed from the project-config ASCE 7 site data (From project ' "config). Only Manual is built today -- choosing the other refuses " 'to run rather than fabricate a pressure.', False, 'text', 'Structural Model', 'Wind', 'stage3'), _F('seismic_cs', 'Seismic coefficient', u'Cₛ, % of dead load', "The seismic force on the panel, as a percentage of its own dead " 'weight. Not yet used by the solve.', False, 'num', 'Structural Model', 'Seismic', 'stage3'), # ============================================================= # Handling (Eriksson p.6-7; the stage matrix itself is `STAGE_OPTIONS` / # `stage_matrix()` below, not a FIELDS entry -- see that section's # docstring for why) # ============================================================= _F('weight_lb', 'Panel weight (override)', 'lb', "The panel's weight, if you already know it. Leave blank to have " 'it computed from the geometry and unit weight above.', False, 'num', 'Handling', None, 'solve'), _F('self_weight_factor', 'Self-weight load factor', '', "A multiplier on the panel's own weight for the handling load " 'cases (e.g. 1.5 for a dynamic lift) when a stage does not set its ' 'own. Blank uses the built-in default (1.0).', False, 'num', 'Handling', None, 'solve'), _F('face_lifters', 'Face lifters', 'column @ fraction of width', 'Where the flat-face lifting inserts are, as a column position ' 'and a fraction across the width. Not yet used by the solve.', False, 'text', 'Handling', 'Lifter & Dunnage Positions', 'stage3'), _F('edge_lifters', 'Edge lifters', 'column @ fraction of width', 'Where the edge lifting inserts are, the same way as the face ' 'lifters above. Not yet used by the solve.', False, 'text', 'Handling', 'Lifter & Dunnage Positions', 'stage3'), # ============================================================= # Reinforcement (Eriksson p.8) # ============================================================= # Owner ruling, 2026-09-07 (issue #23, items 9b / 9e): once the # `strand_row` table has any rows, `ps_count` is DISPLAY ONLY -- the sum # of the rows' own counts, never a second number typed beside them # (`pa_reinforcement._strand_block` renders it as a kv row then, and # `_prestress_from` no longer compares it against the rows) -- and the # rows are the ONLY source of initial pull: `ps_pull_lb` is not shown on # the page any more and is not a fallback for a row that left its pull # blank (that row is an error naming the row). The key is kept declared # because the model read (`pa_reinf.strand_prefill`) still supplies it # and `prefill` seeds each new row's `pull_lb` from it. _F('ps_count', 'Strand count', '', 'The total number of prestressing strands. Once the strand rows ' 'below exist this is their sum, shown for reference, not typed.', False, 'int', 'Reinforcement', 'Prestressing Strands', 'solve'), _F('ps_dia_in', 'Strand diameter', 'in', 'The prestressing strand size (e.g. 1/2, 1/2 SP, 3/8), used to look ' 'up its cross-sectional area for any strand row that does not give ' 'its own.', False, 'text', 'Reinforcement', 'Prestressing Strands', 'solve'), _F('ps_pull_lb', 'Initial pull per strand (model read)', 'lb', 'The jacking force per strand read off the model. Not shown on the ' 'page: it only seeds the initial pull of strand rows at pre-fill, ' 'and every row carries its own.', False, 'num', 'Reinforcement', 'Prestressing Strands', 'solve'), # `ps_rows` (free text, 'count @ depth_in [@ pull_pct]' per line) is GONE # -- owner ruling 2026-09-04 ("rebar 怎么可能是打字上去?而不是进入 # model 的?"): the strand schedule is a ROW TABLE now, `STRAND_ROW_TABLE` # (count / depth_in / dia_in / pull_lb per row), not a text box. See # `_prestress_from`. _F('ps_loss_pct', 'Assumed prestress loss', '%', 'How much of the initial pull is assumed lost to shrinkage, creep ' 'and relaxation by the time it matters. Blank uses the built-in ' 'default.', False, 'num', 'Reinforcement', 'Prestressing Strands', 'solve'), _F('strand_rail_location', 'Strand rail location', '', 'Which casting-bed rail the strands run along. Read when placing ' 'the model-read strand positions across the width ' '(`_model_strand_x_ft`): Right edge mirrors, Centerline offsets.', False, 'text', 'Reinforcement', 'Prestressing Strands', 'stage3'), _F('strand_rail_offset_in', 'Strand rail offset', 'in', 'How far the strands sit off the rail. Not yet used by the solve.', False, 'num', 'Reinforcement', 'Prestressing Strands', 'stage3'), # `mild_as` (free text, comma-joined mark names / callouts) is GONE -- # same 2026-09-04 ruling as `ps_rows`: mild reinforcement is a ROW TABLE # now, `MILD_BAR_TABLE` (size / count / depth_in / direction / from_ft / # to_ft / spacing_in per row), not a text box. See `_mild_bars_from`. # Owner ruling, 2026-09-07 (issue #23, item 9g): mesh is a STRUCTURED # input -- a designation that resolves to wire spacing and area # (`wwf_designation`, shown resolved on the page), a layer count and a # depth to its CG -- not a bare text box. The designation stays typed # (and refused by name when it does not parse) rather than a dropdown, # because WHICH designations a plant stocks is plant knowledge nobody # has supplied yet; the capacity side is the companion engine issue. _F('mesh', 'Welded wire mesh', 'WWF', "The welded wire mesh designation, spacing then wire size, e.g. " "'6x6-W2.9xW2.9' (longitudinal x transverse; W = plain wire, D = " "deformed; the number is the wire area in hundredths of a square " "inch). Resolved to wire spacing and area below. Not yet used by " "the solve.", False, 'text', 'Reinforcement', 'Mild Reinforcement', 'stage3'), _F('mesh_layers', 'Mesh layers', '', 'How many layers of the welded wire mesh above the panel carries. ' 'Leave blank if there is none or it is unknown. Not yet used by the ' 'solve.', False, 'int', 'Reinforcement', 'Mild Reinforcement', 'stage3'), _F('mesh_depth_in', 'Mesh depth to CG', 'in', 'How deep the mesh sits, from the face the strand depths are ' 'measured from to the centre of the wires. For a sandwich panel ' 'with a layer in each wythe, the depth of the first layer. Not yet ' 'used by the solve.', False, 'num', 'Reinforcement', 'Mild Reinforcement', 'stage3'), ] # The prestress fields form one optional group: all blank (and no # `strand_row` table rows) means "no prestress", either filled means # "describe it fully". `ps_ecc_in` is gone, and so -- 2026-09-04 -- is # `ps_rows`: the strand schedule is the `STRAND_ROW_TABLE` row table now, # not a text field, so it is no longer one of these four TEXT keys # (`_prestress_from` checks the table separately). See `parse_strand_rows`, # still used at PREFILL time to seed the table from a text read. # `ps_pull_lb` left this tuple on 2026-09-07 (issue #23, 9e): it is no # longer a form input, so a model-read value sitting in it must not by # itself mean "prestress described" -- only the rows and the two typed # scalars do. _PRESTRESS_KEYS = ('ps_count', 'ps_dia_in') # Parsed inline by `_prestress_from` rather than the generic num/int loop in # `validate`, alongside `_PRESTRESS_KEYS` -- so they are skipped there too. _PRESTRESS_EXTRA_KEYS = ('ps_loss_pct', 'ps_fpu_ksi') # Keys the model can pre-fill off the framing element. Anything the button # could not read is absent (or None) and comes up blank. PREFILLABLE = ('mark', 'design_number', 'length_ft', 'width_ft', 'thickness_in', 'fc_psi', 'fci_psi', 'unit_weight_pcf', 'weight_lb', 'concrete_type', 'section_type', 'wythe_top_in', 'insulation_in', 'wythe_bottom_in', 'fc2_psi', 'fci2_psi', 'unit_weight2_pcf', 'project_name', 'client_name', 'design_date', 'job_number', 'conc_ec_psi', 'conc_eci_psi', 'rebar_fy_ksi', 'rebar_es_ksi', 'mesh_fy_ksi', 'mesh_es_ksi', 'ps_fpu_ksi', 'strand_ep_ksi', 'ps_dia_in', 'ps_count', 'ps_pull_lb', # Read off the assembly's members / the design spec, not off a # panel parameter (see the button's `_read_reinforcement`). # `ps_rows` / `mild_as` are read as ROW-TABLE SOURCES (see # `prefill`'s row-tables section), not through this whitelist # -- they no longer name a `FIELDS` entry to pre-fill. 'mesh', 'mesh_layers') + SETTINGS_KEYS # mark / design_number are shown as a header per panel rather than as fields # inside a section; they still pre-fill and ride into the deck. _HEADER_KEYS = ('mark', 'design_number') # Reads the button hands to `prefill` that are STRUCTURED VALUES, not # display strings -- `openings_rows` (the grouped opening/reveal rows the # Concrete Extents page and the sketches consume) and, 2026-09-04, # `strand_x_in` (a flat per-strand x list, inches from the left rail, a # later reads sibling supplies -- `pa_sketch_reinf.section`'s own # `_explicit_strand_x` is the reader). A structured value must never be run # through `_fmt` (a list would come out as its repr), so `prefill` copies # these verbatim under their own keys and the `PREFILLABLE` string loop # never sees them. PASSTHROUGH_KEYS = ('openings_rows', 'strand_x_in') # Reads consumed as the SOURCE text/list for seeding a ROW TABLE at prefill # time (`restraint`, `strand_row`, `mild_bar` -- see prefill's "row tables" # section), never a `FIELDS` entry any more. Named here so the generic # "every other read the button recovered lands too" pass below does not # also dump the raw text into `values` under these keys -- it is read # straight off `reads` where it is used, and would otherwise sit in # `values` unused and confusing (there is no editor for a key like # 'ps_rows' any more). `mild_bar_rows`, the structured alternative to # `mild_as`, is a PASSTHROUGH_KEYS-shaped list read the same way # `openings_rows` is -- it is read directly off `reads`, never through # `values`, so it needs no entry here. _ROW_TABLE_SOURCE_READ_KEYS = ('restraints', 'ps_rows', 'mild_as') # Fields that are only meaningful for a Sandwich (insulated) panel: the two # concrete wythes' thicknesses + the insulation between them (Concrete # Extents / Sandwich Wythes) and the SECOND wythe's material properties # (Design Criteria / Material Properties). For this tool a section is Solid # or Sandwich and nothing else, so these are shown ONLY while the panel's # `section_type` is Sandwich: the pa_* section modules emit their rows, and # the renderer builds each one collapsed and flips it when the section_type # editor changes (see script.py's `_render_field` / PanelForm). SANDWICH_ONLY_KEYS = ('wythe_top_in', 'insulation_in', 'wythe_bottom_in', 'fc2_psi', 'fci2_psi', 'unit_weight2_pcf') # Fields whose value is really a pick-list, so the form shows a dropdown # instead of a free-text box (owner, 2026-09-03: "很多东西应该是选项,而不是让 # 用户填写" -- many of these should be options, not something the user types). # The renderer (script.py `_render_field`) upgrades any of these keys to an # editable ComboBox over these options when the section module rendered the # field as plain text/number; a field a section module already declares a # 'choice' keeps its own options. The box stays EDITABLE -- these are the # common answers, never an enforced set and never a default (the box opens on # the pre-filled value, or blank). Every option is generic precast / ASCE # nomenclature and carries no client or job data. FIELD_CHOICES = { 'concrete_type': ('Normalweight', 'Lightweight', 'Sand-Lightweight'), 'section_type': ('Solid', 'Sandwich'), 'top_in_form': ('Interior', 'Exterior'), 'left_miter': ('Yes', 'No'), 'right_miter': ('Yes', 'No'), 'second_order': ('Yes', 'No'), 'cracking_assumption': ('Cracked', 'Uncracked'), 'ps_loss_method': ('PCI', 'AASHTO'), 'ps_dia_in': STRAND_DIA_CHOICES, # The ASCE wind category dropdowns (risk / exposure / enclosure / code # version / C&C / MWFRS chapter) are GONE -- 2026-09-04 owner ruling, # site data moved to project config; see the module docstring's Wind # section and `_wind_from`. `wind_mode` is the one wind pick-list left # on this form. 'wind_mode': WIND_MODE_CHOICES, # Added 2026-09-04, owner: "很多东西应该是选项" -- governing code and the # rest of the enumerated Design Criteria / Structural Model settings # become pick-lists too. Every ComboBox stays EDITABLE (script.py's # `make_editor`), so these are offered options, never an enforced set -- # a value outside the list still round-trips. 'design_spec': ('ACI 318-19', 'ACI 318-14', 'ACI 318-11'), 'design_criteria.analysis_beta_d': ('Non-Sway', 'Sway'), 'design_criteria.cracking_method': ('Mast Method', 'Uncracked', 'Cracked'), 'design_criteria.flexure_method': ('Whitney Stress Block', 'Strain Compatibility'), 'design_criteria.flexure_cracking_check': ('None', '1.2 Mcr', '1.33 Mu'), 'design_criteria.deflection_cantilever_limit': ('L/75', 'L/120', 'L/150'), 'deflection_limit': ('L/150', 'L/120', 'L/240', 'L/360'), 'load_opt_offset_from': ('Deflection Section', 'Top in Form', 'Bottom in Form'), 'seismic_apply_to': ('None', 'Out-of-plane', 'In-plane', 'Both'), 'restraint_foundation_connection': ('Pin', 'Fixed', 'Free'), 'strand_rail_location': ('Left edge', 'Right edge', 'Centerline'), } def choices_for(key): """The dropdown options for a pick-list field, or None for a free-text one. Consulted by the renderer to turn a field a section module drew as plain text into a ComboBox. Returns None (free text) for any key not listed -- the could-not-judge default, never an empty tuple that would render an empty dropdown. The words `wind_enclosure` returns normalize cleanly through `_normalize_enclosure` ('Partially enclosed' -> 'partially_enclosed'), so the GCpi readout keeps working when that field is a dropdown. """ opts = FIELD_CHOICES.get(key) return tuple(opts) if opts else None def _spec(key): for f in FIELDS: if f['key'] == key: return f return None def label_of(key): """The plain-English label alone (no symbol) -- 'Piece mark', not 'PM'.""" if key == 'mark': return 'Piece mark' if key == 'design_number': return 'Design number' spec = _spec(key) return spec['label'] if spec else key def symbol_of(key): """The engineering notation + unit shown beside the label, or ''.""" spec = _spec(key) return (spec['symbol'] if spec else '') or '' def help_of(key): """The one-sentence plain-language help line, or '' if there is none.""" spec = _spec(key) return (spec['help'] if spec else '') or '' def display_label(key): """'label — symbol' for messages a person reads, e.g. errors. Both halves matter here: the plain word for someone new to the form, the engineering notation for someone checking this against Eriksson's own printout. Never drop either. """ label = label_of(key) symbol = symbol_of(key) if symbol: return u'{0} — {1}'.format(label, symbol) return label def required_keys(): return [f['key'] for f in FIELDS if f['required']] def sections(): """[(section_name, [(group_name, [field, ...]), ...]), ...]. In display order: `SECTION_ORDER`, and inside each section the fields with no `group` (Concrete Extents' own geometry, Structural Model's supports, ...) come first as a `(None, [...])` bucket, followed by each named group in the order it first appears in `FIELDS` -- Eriksson's own page order, since `FIELDS` is written in that order. The Handling section's Stage Options matrix is NOT a group here -- it has no per-field rows, it is a grid. The button renders it separately, via `stage_matrix()`, positioned inside the Handling section alongside these groups (see that function's docstring). """ out = [] for name in SECTION_ORDER: secfields = [f for f in FIELDS if f['section'] == name] ungrouped = [f for f in secfields if not f['group']] groups = [] if ungrouped: groups.append((None, ungrouped)) seen = [] for f in secfields: g = f['group'] if g and g not in seen: seen.append(g) for g in seen: groups.append((g, [f for f in secfields if f['group'] == g])) out.append((name, groups)) return out # --------------------------------------------------------------- formatting def _fmt(value): """A model-read value as the string the form shows. None -> blank.""" if value is None: return '' if isinstance(value, bool): return '' if isinstance(value, float): if value == int(value): return str(int(value)) return ('%.4f' % value).rstrip('0').rstrip('.') return str(value) def prefill(reads, know=None): """Initial form values from what the model supplied. `reads` carries the `PREFILLABLE` keys the button read off the framing element, each STRING-valued, PLUS the structured `PASSTHROUGH_KEYS` (today `openings_rows`, a list of opening/reveal row dicts) copied through verbatim. A key that is absent or None comes up blank -- the owner's rule: pre-fill what is readable, blank the rest, and let the user fill the blanks before anything runs. Every field the model cannot supply (supports, load factors, prestress, the stage matrix, all the stage3 in-place inputs) starts blank too. `know` is optional -- the merged knowledge-layer mapping (job/plant/config, same shape `knowledge.load()` returns) the caller may hand through so a sibling `vibe.pa_defaults.apply_defaults(values, know)` can fill in everything the owner ruled a sane, plant-agnostic default rather than a real read ("按一下 panel analysis,然后手动输入 loading,剩下的它就能跑 了", 2026-09-04). A caller that passes nothing behaves exactly as before -- `know` defaults to `None` and `pa_defaults` is free to treat that as "no plant layer available" rather than an error. """ reads = reads or {} values = {} for f in FIELDS: key = f['key'] values[key] = _fmt(reads.get(key)) if key in PREFILLABLE else '' # PREFILLABLE keys that the pa_* section modules render under their own # labels (job number, the elastic moduli, mesh, strand) are not all FIELDS # entries; give every one a starting value so its editor opens on the read # value rather than blank. for key in PREFILLABLE: if key not in values: values[key] = _fmt(reads.get(key)) for key in _HEADER_KEYS: values[key] = _fmt(reads.get(key)) # EVERY OTHER read the button recovered lands too. `PREFILLABLE` used to # be the only door, and it silently dropped the model-measured supports, # lifters, elevations and restraints the reads had already found -- # measured 2026-09-04 on a real wall: `supports_basis` said "6 support # positions" while the form opened with `supports_ft` blank, which is # exactly the blank the owner saw that morning. A read the button went to # the trouble of making is the form's to show; the whitelist above only # says which keys are EXPECTED, not which are allowed. `*_basis` reads # (the one-sentence provenance beside each read) are kept together under # `_basis` so the runlog and the page can show why a field is what it is # -- they are not fields and `validate` / `build_deck` never see them. basis = {} for key, value in reads.items(): if key.endswith('_basis'): basis[key] = value continue if key in PASSTHROUGH_KEYS or key in _HEADER_KEYS \ or key in _ROW_TABLE_SOURCE_READ_KEYS: continue if key in values and (values.get(key) or '').strip(): continue if value is None or isinstance(value, (list, dict)): continue values[key] = _fmt(value) values['_basis'] = basis # Structured reads (openings_rows) pass through as the LIST the section # module / sketches read -- never `_fmt`'d into a repr string. A missing # key -> [] (a real "no openings" answer), so the page builds a clean # empty table instead of degrading to its "not present" fallback. for key in PASSTHROUGH_KEYS: value = reads.get(key) values[key] = value if isinstance(value, list) else [] for key in stage_field_keys(): values[key] = '' # Constant, generic defaults -- only where the model read nothing, so a # real value always wins (owner, 2026-09-03: normal weight, rebar 60/29000, # mesh 80/29000, 270 ksi strand). for key, default in PREFILL_DEFAULTS.items(): if not (values.get(key) or '').strip(): values[key] = default # A Sandwich panel's second wythe is normally the SAME concrete as its # first -- only an architectural/lightweight face wythe differs. So where # the piece is a Sandwich and a wythe-2 material field is still blank, # seed it from the wythe-1 value as a starting assumption the user can # overwrite; a real read value always wins, and a Solid panel gets no # such copy (the fields stay blank for a Solid section). if (values.get('section_type') or '').strip().lower() == 'sandwich': for pair in (('fc2_psi', 'fc_psi'), ('fci2_psi', 'fci_psi'), ('unit_weight2_pcf', 'unit_weight_pcf')): target, source = pair if not (values.get(target) or '').strip(): values[target] = values.get(source) or '' # Elastic moduli: compute from f'c / f'ci + unit weight so they arrive # filled (owner: "Eci, Ec 你不会算?"). The renderer keeps them live as the # source fields change, until the user overrides them. for key in DERIVED_FIELDS: if not (values.get(key) or '').strip(): dv = derived_value(key, values) if dv is not None: values[key] = _fmt(round(dv)) # Pre-tick the default load combinations (owner: strength 1,2,5,7 / # service 1,2,5,9); every other combo's tick stays absent -> unchecked. for name, nums in LOAD_COMBO_DEFAULT_INCLUDED.items(): for n in nums: values[load_combo_include_key(name, n)] = 'yes' # Seed every code row's FACTORS as editable starting values (owner, # 2026-09-03: the factors are the user's to change). The ACI defaults go in # so a fresh form shows the code table rather than a grid of empty boxes, # and anything the user types then simply replaces the seed. for name, rows in LOAD_COMBO_ROWS.items(): for row in rows: for i, col in enumerate(LOAD_COMBO_FACTORS): values[load_combo_factor_key(name, row[0], col)] = row[i + 1] # ----------------------------------------------------------- row tables # The owner's 2026-09-04 rulings: restraints must be pre-filled but # EDITABLE (never locked); wind becomes purely user-typed pressure/ # suction zones; concentrated loads need an editor at all; and the two # reinforcement schedules are rows a model reads into, not text a person # types. Seeded here, straight off `reads` (not `values`), after every # generic model-read / default pass above so 'width_ft', 'length_ft', # 'bottom_elevation_ft', 'top_elevation_ft', 'ps_dia_in' and # 'ps_fpu_ksi' are already whatever this panel's `values` will show. # `restraint`: seeded from the read `restraints` text -- the SAME # comma/whitespace-separated elevation list the button has always # supplied under that key; only the free-text box it used to fill is # gone. Row 1 (the lowest-numbered reading -- the panel's own # foundation) defaults to 'Pin'; every other row defaults to 'Floor # tie', Eriksson's own restraint-type vocabulary. A read that does not # parse seeds nothing (never a partial or guessed table). restraint_xs, _err = parse_supports(reads.get('restraints')) for i, elev in enumerate(restraint_xs or [], 1): add_row(RESTRAINT_TABLE, values, RESTRAINT_COLS, defaults={ 'elevation_ft': _fmt(elev), 'type': 'Pin' if i == 1 else 'Floor tie', 'gravity': 'yes', }) # `support`: seeded from the SAME `supports_ft` model read the old # free-text field used to show directly -- one editable row per bearing # position (owner ruling, 2026-09-05). A read that does not parse seeds # no rows, same discipline as `restraint` above. support_xs, _err = parse_supports(reads.get('supports_ft')) for x in (support_xs or []): add_row(SUPPORT_TABLE, values, SUPPORT_COLS, defaults={'x_ft': _fmt(x)}) # `opening`: seeded from the model's OWN `openings_rows` read (the # button's `_read_openings`, one row per hole/blind recess it found on # the panel's own geometry, always `pos_along_from='start'` / # `pos_cross_from='Face'` per that reader's own contract -- so no # `_from` word needs interpreting here, the same simplification # `restraint` already made for elevations). Owner ruling, 2026-09-05: # one editable row per opening/reveal/solid zone, in this table's own # x_ft/length_ft (along the length, from the start) / y_ft/width_ft # (across the width, from the bottom) / depth_in (a Reveal's # through-thickness cut, converted from the model read's own feet-deep # `width` -- blank for an Opening, which always goes full depth). # `values['openings_rows']` (below, PASSTHROUGH_KEYS) stays populated # with the model's raw list too, as `validate`'s fallback for a panel # saved before this table existed. for _orow in (reads.get('openings_rows') or []): if not isinstance(_orow, dict): continue _rtype = (_orow.get('type') or 'Opening').strip() if _rtype not in OPENING_TYPE_CHOICES: _rtype = 'Opening' if _rtype.lower() != 'reveal' else 'Reveal' _depth_in = '' if _rtype == 'Reveal' and _orow.get('width') is not None: try: _depth_in = _fmt(round(float(_orow['width']) * 12.0, 3)) except (TypeError, ValueError): _depth_in = '' add_row(OPENING_TABLE, values, OPENING_COLS, defaults={ 'type': _rtype, 'x_ft': _fmt(_orow.get('pos_along')), 'length_ft': _fmt(_orow.get('length')), 'y_ft': _fmt(_orow.get('pos_vert')), 'width_ft': _fmt(_orow.get('height')), 'depth_in': _depth_in, }) # `face_lifter` / `edge_lifter`: seeded from the model's legacy # `face_lifters` / (`top_edge_lifters` + `side_edge_lifters`, else # `edge_lifters`) text reads -- one editable row per lifter (owner # ruling, 2026-09-05). Needs `width_ft`/`length_ft`, which the generic # model-read pass above has already landed in `values` by this point. _fl_width_ft, _fl_werr = _parse_num(values.get('width_ft')) _fl_length_ft, _fl_lerr = _parse_num(values.get('length_ft')) if _fl_werr: _fl_width_ft = None if _fl_lerr: _fl_length_ft = None for _fx, _ffrac in _parse_xfrac_lines(reads.get('face_lifters')): if not _fl_width_ft: continue add_row(FACE_LIFTER_TABLE, values, FACE_LIFTER_COLS, defaults={ 'x_ft': _fmt(_fx), 'y_ft': _fmt(round(_ffrac * _fl_width_ft, 4)), }) _top_edge_rows = _parse_xfrac_lines(reads.get('top_edge_lifters')) _side_edge_rows = _parse_xfrac_lines(reads.get('side_edge_lifters')) if _top_edge_rows or _side_edge_rows: for _ex, _efrac in _top_edge_rows: _edge, _epos = _edge_lifter_row_from_legacy( _ex, _efrac, _fl_length_ft, _fl_width_ft, 'end') if _epos is None: continue add_row(EDGE_LIFTER_TABLE, values, EDGE_LIFTER_COLS, defaults={ 'edge': _edge, 'position_ft': _fmt(_epos)}) for _ex, _efrac in _side_edge_rows: _edge, _epos = _edge_lifter_row_from_legacy( _ex, _efrac, _fl_length_ft, _fl_width_ft, 'side') add_row(EDGE_LIFTER_TABLE, values, EDGE_LIFTER_COLS, defaults={ 'edge': _edge, 'position_ft': _fmt(_epos)}) else: for _ex, _efrac in _parse_xfrac_lines(reads.get('edge_lifters')): _edge, _epos = _edge_lifter_row_from_legacy( _ex, _efrac, _fl_length_ft, _fl_width_ft, None) if _epos is None: continue add_row(EDGE_LIFTER_TABLE, values, EDGE_LIFTER_COLS, defaults={ 'edge': _edge, 'position_ft': _fmt(_epos)}) # `conc_load`: NO rows -- concentrated loads are real LOADING, always # the user's to type (owner, 2026-09-02: "现在就只做用户手动输入的部 # 分"), never guessed from the model. The table starts empty and # editable. # `wind_zone`: one row covering the panel's own bottom-to-top elevation # at its full width, pressure and suction left BLANK for the user to # type (owner, 2026-09-04: "手动输入不仅仅是 windload,还有 apply 在 # 什么位置,从哪个 elevation 到哪个 elevation,多大的宽度"). Either end # being unreadable leaves that part of the seeded row blank rather than # a guessed elevation -- the row is still there, still editable. # # `top_elevation_ft` and `length_ft` are two SEPARATE measurements (a # bbox Z extent vs. whatever `length_ft` was measured off) that agree to # within `pa_elevation`'s own tolerance but are not always bit-identical # -- WPA655 measured 37'-8" of elevation against a 37'-7" length. A # default row built from the raw elevation read can then overhang the # panel `validate()` actually checks against by that same fraction of an # inch, so the prefilled `to_elev_ft` is clamped here to # bottom + length_ft when the two disagree, rather than handing the user # a default that already needs `validate`'s 1" tolerance to pass. _wz_bottom = values.get('bottom_elevation_ft') _wz_top = values.get('top_elevation_ft') _wz_length = values.get('length_ft') try: _wz_bottom_f = float(_wz_bottom) except (TypeError, ValueError): _wz_bottom_f = None try: _wz_top_f = float(_wz_top) except (TypeError, ValueError): _wz_top_f = None try: _wz_length_f = float(_wz_length) except (TypeError, ValueError): _wz_length_f = None _wz_to = _wz_top or '' if _wz_bottom_f is not None and _wz_length_f is not None: _wz_clamped_top = _wz_bottom_f + _wz_length_f if _wz_top_f is None or abs(_wz_top_f - _wz_clamped_top) > 1e-6: _wz_to = _fmt(round(_wz_clamped_top, 4)) add_row(WIND_ZONE_TABLE, values, WIND_ZONE_COLS, defaults={ 'label': 'W1', 'from_elev_ft': _wz_bottom or '', 'to_elev_ft': _wz_to, 'width_ft': values.get('width_ft') or '', }) if not (values.get('wind_mode') or '').strip(): values['wind_mode'] = 'Manual' # `strand_row`: prefers the STRUCTURED `strand_rows` read -- a sibling # reads module supplies `[{'count','depth_in','dia_in','pull_lb'}, ...]` # straight off the model -- when it is a real, non-empty list; falls # back to the text parse of `ps_rows` ('count @ depth_in [@ pull_pct]' # per line via `parse_strand_rows`) exactly as before when it is not. # # BOTH paths now seed every row's `dia_in` and `pull_lb` rather than # leaving them blank: `dia_in` from the row's own reading, or else the # scalar `ps_dia_in` mapped through `strand_dia_choice` onto the # table's own fraction vocabulary ('0.5' -> '1/2'); `pull_lb` from the # row's own reading (or a pull_pct resolved against fpu/diameter, text # path only), or else the scalar `ps_pull_lb`. A row that came up # blank in both used to leave the Aps/cgy summary reading "unknown # (unrecognised strand size)" even though the scalars right above the # table carried a perfectly good answer. # Owner ruling, 2026-09-05: the model read for `ps_dia_in` is a bare # decimal ('0.5'), but the FIELD ITSELF is a `choice` editor whose # options are `strand_dia_choice`'s own fraction vocabulary ('1/2', # not '0.5') -- see `pa_reinforcement._STRAND_COLUMNS`'s twin # (`_STRAND_SCALAR_KEYS`'s `ps_dia_in`, same choice list). A `