Files
willtools-demo/WillTools.extension/lib/vibe/panelanalysisform.py
T
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

3995 lines
190 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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.<key>` (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.<name>.<n>.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.<table>.<row>.<thing>`
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 `<table>.<n>.<col>` (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.<table>.<n>.<col>`) -- generalised to
# a bare `<table>` 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 '<table>.<n>.<col>' 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 `<select>`
# asked to show a value that is not one of its own options renders as
# if nothing were selected, so the field looked blank even though
# `values['ps_dia_in']` held a perfectly good '0.5' -- normalising IN
# PLACE here (not just into the `default_dia` local used to seed rows)
# is what actually fixes the display, not only the table it used to
# feed.
if (values.get('ps_dia_in') or '').strip():
values['ps_dia_in'] = strand_dia_choice(values['ps_dia_in']) \
or values['ps_dia_in']
default_dia = strand_dia_choice(values.get('ps_dia_in')) or ''
default_pull = values.get('ps_pull_lb') or ''
strand_rows_read = reads.get('strand_rows')
if isinstance(strand_rows_read, list) and strand_rows_read:
for r in strand_rows_read:
if not isinstance(r, dict):
continue
row_dia = strand_dia_choice(r.get('dia_in')) or default_dia
row_pull = _fmt(r.get('pull_lb')) or default_pull
add_row(STRAND_ROW_TABLE, values, STRAND_ROW_COLS, defaults={
'count': _fmt(r.get('count')),
'depth_in': _fmt(r.get('depth_in')),
'dia_in': row_dia, 'pull_lb': row_pull,
})
else:
strand_groups, _problems = parse_strand_rows(reads.get('ps_rows'))
for g in strand_groups or []:
pull_text = ''
if g.get('pull_pct') is not None:
area, area_err = strand_area_in2(values.get('ps_dia_in'))
fpu_val, _e = _parse_num(values.get('ps_fpu_ksi'))
if not area_err and area is not None and fpu_val is not None:
pull_text = _fmt(round(
(g['pull_pct'] / 100.0) * fpu_val * 1000.0 * area, 1))
if not pull_text:
pull_text = default_pull
add_row(STRAND_ROW_TABLE, values, STRAND_ROW_COLS, defaults={
'count': _fmt(g['count']), 'depth_in': _fmt(g['depth_in']),
'dia_in': default_dia, 'pull_lb': pull_text,
})
# `ps_count`: seed as the sum of whatever strand rows just landed, when
# the scalar itself is still blank -- the same "row count sums into the
# scalar" relationship `pa_reinforcement._strand_block` shows live as a
# hint beside the field, applied once here so a fresh form already
# agrees with its own table instead of opening on an unrelated blank.
if not (values.get('ps_count') or '').strip():
total = 0
counted_any = False
for n in row_ids(STRAND_ROW_TABLE, values):
c = values.get(row_key(STRAND_ROW_TABLE, n, 'count'))
try:
total += int(round(float(c)))
counted_any = True
except (TypeError, ValueError):
pass
if counted_any:
values['ps_count'] = _fmt(total)
# `mild_bar`: from the structured `mild_bar_rows` read when the button
# supplies one (a later reads sibling; the same PASSTHROUGH_KEYS-shaped
# pattern `openings_rows` uses, read straight off `reads`, never
# `_fmt`'d), else a best-effort parse of the `mild_as` summary text --
# today that text is just mark names with no size ("MC_S03, MC_S04"),
# so this seeds no rows from it yet; the table exists precisely so a
# size/count/depth this cannot read is typed by hand.
mild_rows_read = reads.get('mild_bar_rows')
if isinstance(mild_rows_read, list):
for r in mild_rows_read:
if not isinstance(r, dict):
continue
add_row(MILD_BAR_TABLE, values, MILD_BAR_COLS, defaults={
'size': _fmt(r.get('size')), 'count': _fmt(r.get('count')),
'depth_in': _fmt(r.get('depth_in')),
'direction': _fmt(r.get('direction')) or 'Longitudinal',
'from_ft': _fmt(r.get('from_ft')) or '0',
'to_ft': (_fmt(r.get('to_ft')) or values.get('length_ft')
or ''),
'spacing_in': _fmt(r.get('spacing_in')),
})
else:
for parsed_bar in _parse_mild_as_summary(reads.get('mild_as')):
add_row(MILD_BAR_TABLE, values, MILD_BAR_COLS, defaults={
'size': parsed_bar['size'],
'count': _fmt(parsed_bar.get('count')),
'direction': 'Longitudinal', 'from_ft': '0',
'to_ft': values.get('length_ft') or '',
})
# The sibling defaults module, wired in LAST so it only ever fills what
# is still blank after every rule above -- a real model read, a plant
# setting and the generic PREFILL_DEFAULTS all win over it. Wrapped in
# `try/except Exception` (not just ImportError) because `pa_defaults` is
# being written in parallel and can be missing or briefly unimportable;
# either way `prefill` must degrade to exactly today's behaviour, never
# raise. Only on SUCCESS is `_defaults_applied` set, to the exact list
# `apply_defaults` returns (even `[]`, the honest "ran, filled nothing"
# answer) -- "could not run" and "ran and found nothing to fill" must
# not collapse into the same observable state (CLAUDE.md SS3), so a
# missing/broken module leaves no `_defaults_applied` key at all rather
# than a fake empty one. `_defaults_applied` is not a `FIELDS` key, so
# `validate()` / `build_deck()` / `batch_plan()` -- which all walk
# `FIELDS`, never `values.keys()` -- never see it.
try:
from vibe import pa_defaults
applied = pa_defaults.apply_defaults(values, know)
values['_defaults_applied'] = applied if isinstance(applied, list) \
else []
except Exception:
pass
return values
# ------------------------------------------------------------------ parsing
def _parse_num(text):
"""(value, error). Blank -> (None, None). Non-number -> (None, reason)."""
if text is None:
return None, None
text = text.strip()
if not text:
return None, None
try:
return float(text), None
except (TypeError, ValueError):
return None, 'is not a number ({0!r})'.format(text)
def _parse_int(text):
if text is None:
return None, None
text = text.strip()
if not text:
return None, None
try:
n = int(round(float(text)))
except (TypeError, ValueError):
return None, 'is not a whole number ({0!r})'.format(text)
if n <= 0:
return None, 'must be a positive count ({0!r})'.format(text)
return n, None
def _parse_bool(text):
"""(value, error). Blank -> (None, None) -- "unspecified", not "no"."""
if text is None:
return None, None
t = text.strip().lower()
if not t:
return None, None
if t in ('y', 'yes', 'true', '1', 'on', 'include', 'included'):
return True, None
if t in ('n', 'no', 'false', '0', 'off', 'exclude', 'excluded'):
return False, None
return None, 'is not yes/no ({0!r})'.format(text)
def parse_supports(text):
"""(positions, error). Blank -> ([], None); the required gate flags empty.
Splits on commas and whitespace. A non-numeric token is refused by name
rather than dropped. The <2 rule is applied in `validate`, so this can be
reused wherever a raw list is wanted.
"""
if text is None:
text = ''
text = text.strip()
if not text:
return [], None
xs = []
for tok in re.split(r'[,\s]+', text):
if not tok:
continue
try:
xs.append(float(tok))
except (TypeError, ValueError):
return None, ('support positions must be numbers in feet; '
'could not read {0!r}'.format(tok))
return xs, None
_OPENING_TYPES = ('opening', 'reveal', 'solid zone')
_ALONG_DATUMS = ('start', 'end')
_CROSS_DATUMS = ('left', 'right')
def parse_openings(text):
"""(rows, problems) -- one opening/reveal/solid-zone per LINE,
all-or-nothing by line.
Grammar (a real Eriksson output PDF, seen after the first cut of this
module, is what fixed this shape -- no text from that file is quoted
here, only its column structure):
type, pos_along, pos_along_from, length, pos_cross, pos_cross_from,
width, pos_vert, height [, label]
matching Eriksson's own "Openings, Reveals, and Solid Zones" table: a
TYPE column (Opening / Reveal / Solid Zone), a "Position along Length"
pair (a distance + which END it is measured from: start/end), and a
"Position in Cross Section" quad (a horizontal distance + which SIDE it
is measured from: left/right, a width, a vertical distance, a height).
Eriksson prints the datum glued to the value ('8" from Start'); this
form keeps the number and the datum word as two separate tokens instead,
so a line that garbles or omits the datum is refused by name rather than
a side being silently assumed.
Distances are decimal FEET, matching every other measurement on this
form (Eriksson's own printout uses inches for these two positions; that
conversion happens by eye when transcribing, the same as the ksi/psi
conversion on the Material Properties fields, and is called out in
`ps_dia_in`'s sibling fields for the same reason).
Example: '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'.
Same all-or-nothing discipline as `vibe.extractrules`: a line that does
not parse is reported BY LINE NUMBER naming what was expected, and any
`problems` means `rows` comes back `[]` -- even if other lines parsed
cleanly. A partially-accepted opening list is worse than an empty one:
it looks complete and is not.
Blank text -> ([], []) -- "no openings" is a real, valid answer.
"""
rows = []
problems = []
if text is None:
text = ''
for i, raw in enumerate(text.splitlines(), 1):
line = raw.strip()
if not line:
continue
parts = [p.strip() for p in line.split(',', 9)]
if len(parts) not in (9, 10):
problems.append(
"line {0}: expected 'type, pos_along, pos_along_from, "
"length, pos_cross, pos_cross_from, width, pos_vert, "
"height [, label]', got {1!r}".format(i, line))
continue
line_ok = True
type_txt = parts[0].strip().lower()
if type_txt not in _OPENING_TYPES:
problems.append(
"line {0}: type must be one of Opening / Reveal / Solid "
'Zone (got {1!r})'.format(i, parts[0]))
line_ok = False
along_from = parts[2].strip().lower()
if along_from not in _ALONG_DATUMS:
problems.append(
"line {0}: pos_along_from must be 'start' or 'end' (got "
'{1!r})'.format(i, parts[2]))
line_ok = False
cross_from = parts[5].strip().lower()
if cross_from not in _CROSS_DATUMS:
problems.append(
"line {0}: pos_cross_from must be 'left' or 'right' (got "
'{1!r})'.format(i, parts[5]))
line_ok = False
nums = {}
for name, idx in (('pos_along', 1), ('length', 3), ('pos_cross', 4),
('width', 6), ('pos_vert', 7), ('height', 8)):
txt = parts[idx]
try:
nums[name] = float(txt)
except (TypeError, ValueError):
problems.append('line {0}: {1} is not a number ({2!r})'
.format(i, name, txt))
line_ok = False
if not line_ok:
continue
if nums['length'] <= 0 or nums['width'] <= 0 or nums['height'] <= 0:
problems.append(
'line {0}: length, width and height must all be positive '
'(got length={1}, width={2}, height={3})'.format(
i, nums['length'], nums['width'], nums['height']))
continue
label = parts[9].strip() if len(parts) == 10 else ''
rows.append({
'type': type_txt.title(), 'pos_along': nums['pos_along'],
'pos_along_from': along_from, 'length': nums['length'],
'pos_cross': nums['pos_cross'], 'pos_cross_from': cross_from,
'width': nums['width'], 'pos_vert': nums['pos_vert'],
'height': nums['height'], 'label': label,
})
if problems:
return [], problems
return rows, []
def parse_strand_rows(text):
"""(rows, problems) -- one strand GROUP per LINE, all-or-nothing by line.
Each non-blank line reads 'count @ depth_in [@ pull_pct]' -- e.g.
'4 @ 2 @ 75' is 4 strands, 2 in. from the bottom, pulled to 75% of the
strand's ultimate strength. `pull_pct` is optional; when it is absent the
group uses the form's single default pull (`ps_pull_lb`) instead -- see
`_prestress_from`.
Same all-or-nothing discipline as `parse_openings`: any line that does
not parse is reported by line number naming what was expected, and the
WHOLE field is refused (`rows` comes back `[]`) rather than accepting
the groups that did parse. A strand schedule half-read is not a strand
schedule -- the missing group's steel is real steel the capacity check
would otherwise silently go without.
Blank text -> ([], []) -- "no strand groups typed yet" is not an error by
itself; `_prestress_from` is what decides whether that is allowed given
what else on the prestress group was filled.
"""
rows = []
problems = []
if text is None:
text = ''
for i, raw in enumerate(text.splitlines(), 1):
line = raw.strip()
if not line:
continue
parts = [p.strip() for p in line.split('@')]
if len(parts) not in (2, 3):
problems.append(
"line {0}: expected 'count @ depth_in [@ pull_pct]', got "
'{1!r}'.format(i, line))
continue
count_txt, depth_txt = parts[0], parts[1]
pull_txt = parts[2] if len(parts) == 3 else None
try:
count = int(round(float(count_txt)))
if count <= 0:
raise ValueError()
except (TypeError, ValueError):
problems.append(
'line {0}: strand count is not a positive whole number '
'({1!r})'.format(i, count_txt))
continue
try:
depth = float(depth_txt)
except (TypeError, ValueError):
problems.append('line {0}: depth_in is not a number ({1!r})'
.format(i, depth_txt))
continue
pull_pct = None
if pull_txt is not None:
try:
pull_pct = float(pull_txt)
except (TypeError, ValueError):
problems.append(
'line {0}: pull_pct is not a number ({1!r})'
.format(i, pull_txt))
continue
rows.append({'count': count, 'depth_in': depth, 'pull_pct': pull_pct})
if problems:
return [], problems
return rows, []
# A `ps_dia_in` decimal read maps to the SAME fraction spelling the
# `strand_row` table's own `dia_in` dropdown offers (`pa_reinforcement.
# _STRAND_COLUMNS`: '3/8', '7/16', '1/2', '9/16', '0.6', '0.7'). Both
# spellings already carry the same area in `_STRAND_AREA_IN2` -- this exists
# so a SEEDED row's cell shows a value the dropdown itself lists rather than
# a lookalike the user has to retype, not because the area lookup needs it.
# '0.6' / '0.7' have no fraction spelling in that vocabulary and pass
# through unchanged; anything else unrecognised passes through unchanged too
# -- never invents a size.
_STRAND_DIA_DECIMAL_TO_FRACTION = {
'0.375': '3/8', '0.4375': '7/16', '0.5': '1/2', '0.5625': '9/16',
'0.52': '1/2 SP',
}
def strand_dia_choice(dia_text):
"""`dia_text` normalised to the `strand_row` dropdown's own vocabulary.
A decimal ('0.5') maps to its fraction spelling ('1/2'); a fraction, a
decimal with no fraction spelling ('0.6', '0.7'), blank, or anything
this does not recognise comes back exactly as given -- this never
invents or refuses a size, `strand_area_in2` still does that job.
"""
if dia_text is None:
return dia_text
text = str(dia_text).strip()
if not text:
return text
return _STRAND_DIA_DECIMAL_TO_FRACTION.get(text, text)
def _strand_dia_key(dia_text):
"""The lookup key for a strand size as typed: lower-case, inch marks
and spaces stripped ('1/2" SP' -> '1/2sp', ' 3/8 ' -> '3/8')."""
return str(dia_text).strip().lower().replace('"', '').replace(' ', '')
def strand_dia_nominal_in(dia_text):
"""Nominal diameter in inches for a strand size as typed, or None when
the spelling is not one `_STRAND_DIA_NOMINAL_IN` knows -- never a
guess, the caller decides what an unknown size means."""
if dia_text is None:
return None
key = _strand_dia_key(dia_text)
if key in _STRAND_DIA_NOMINAL_IN:
return _STRAND_DIA_NOMINAL_IN[key]
try:
norm = _fmt(float(key))
except (TypeError, ValueError):
return None
return _STRAND_DIA_NOMINAL_IN.get(norm)
# A WWF designation: spacing pair, a separator, wire pair --
# '6x6-W2.9xW2.9', '6x6 W2.9xW2.9', '4 x 4 - D4 x D4', '6x6-W1.4/W1.4'.
# The first pair is the wire SPACING in inches (longitudinal x transverse),
# the second the WIRE SIZE: W (plain) or D (deformed) followed by the wire's
# section in hundredths of a square inch -- that is what the designation
# means (ASTM A1064 style), not a lookup table, so any well-formed
# designation resolves and a malformed one is refused by name.
# ASCII only on purpose: under IronPython 2.7 a non-ASCII character inside
# a byte-string character class is two bytes, not one glyph.
_WWF_RE = re.compile(
r'^\s*(\d+(?:\.\d+)?)\s*[xX]\s*(\d+(?:\.\d+)?)\s*[-\s:,]*'
r'([WwDd])\s*(\d+(?:\.\d+)?)\s*[xX/]\s*([WwDd])\s*(\d+(?:\.\d+)?)\s*$')
def wwf_designation(text):
"""(resolved, error) for a welded-wire-fabric designation as typed.
`resolved` is `{'spacing_long_in', 'spacing_trans_in',
'wire_long', 'wire_trans', 'wire_area_long_in2', 'wire_area_trans_in2',
'as_long_in2_per_ft', 'as_trans_in2_per_ft'}` -- area per foot of
width is wire area x (12 / spacing). Blank -> (None, None): nothing
typed is not an error. Malformed -> (None, reason naming the text).
"""
if text is None:
return None, None
raw = str(text).strip()
if not raw:
return None, None
m = _WWF_RE.match(raw)
if not m:
return None, (
"mesh designation {0!r} is not readable -- write it as spacing "
"then wire size, e.g. '6x6-W2.9xW2.9'".format(raw))
s_long, s_trans = float(m.group(1)), float(m.group(2))
w_long = m.group(3).upper() + m.group(4)
w_trans = m.group(5).upper() + m.group(6)
a_long = float(m.group(4)) / 100.0
a_trans = float(m.group(6)) / 100.0
if s_long <= 0 or s_trans <= 0 or a_long <= 0 or a_trans <= 0:
return None, (
'mesh designation {0!r}: spacing and wire size must both be '
'positive'.format(raw))
return {
'spacing_long_in': s_long, 'spacing_trans_in': s_trans,
'wire_long': w_long, 'wire_trans': w_trans,
'wire_area_long_in2': a_long, 'wire_area_trans_in2': a_trans,
'as_long_in2_per_ft': a_long * 12.0 / s_long,
'as_trans_in2_per_ft': a_trans * 12.0 / s_trans,
}, None
def strand_area_in2(dia_text):
"""(area, error) for a strand diameter as typed. Unknown size -> reason."""
if dia_text is None:
return None, 'no strand diameter given'
key = _strand_dia_key(dia_text)
if key in _STRAND_AREA_IN2:
return _STRAND_AREA_IN2[key], None
try:
norm = _fmt(float(key))
except (TypeError, ValueError):
norm = None
if norm is not None and norm in _STRAND_AREA_IN2:
return _STRAND_AREA_IN2[norm], None
return None, ('no section area is known for a {0!r} strand; enter a '
'standard size ({1})'.format(
dia_text, ', '.join(sorted(set(
k for k in _STRAND_AREA_IN2 if '/' not in k)))))
# --------------------------------------------------------------------- wind
#
# 2026-09-04 owner ruling: ASCE site data (Vult, risk/exposure/enclosure,
# code version, C&C/MWFRS chapter, mean roof height, effective wind area)
# moves OUT of this form entirely, into project config -- a later "From
# project config" button this form explicitly refuses to run (`validate`,
# the `wind_mode` check). What is left here is purely MANUAL: the user types
# a pressure and/or suction and where it applies, one `wind_zone` row per
# zone (`WIND_ZONE_TABLE` / `WIND_ZONE_COLS`). No pressure is computed and
# none is faked -- `_wind_from` only converts what was typed.
def resolve_wind_pressure(direct_psf, computed_psf, computed_reason=None):
"""Which out-of-plane wind pressure the in-place model uses, and why.
KEPT for `tools/panelanalysis_schema.resolve_wind`, which still calls
this (2026-09-04) from the OLD ASCE-computed path -- this form no
longer builds an `asce` / `direct_pressure_psf` deck block for it to
read (see `_wind_from`), but the schema-side reader is a separate
module under active parallel edit ("teaching wind_zones to read
suction_psf/width_ft per zone"), and this pure precedence function does
not depend on any FIELDS key that left this form -- deleting it would
only have broken that cross-file caller for no gain here. A form built
through THIS module's `build_deck` never reaches it: the new
`loading.wind` block always carries a `zones` key, and `resolve_wind`
short-circuits on that before ever calling this.
Precedence, highest first (the owner's rule -- a hand-entered pressure
always overrides the engine):
direct the user's direct-entry pressure, if given
computed else windload's computed C&C wall pressure
not_judged else, where windload returned NOT JUDGED (h>60, un-mined
ASCE version, bad inputs): NO pressure, the reason carried
so the page shows it -- never a fabricated value
blank else nothing was supplied: the in-place wind case is absent
`computed_reason` is windload's `not_judged` sentence (or None). Returns a
dict {'source', 'pressure_psf', 'reason'}; `pressure_psf` is None for the
not_judged and blank sources.
"""
if direct_psf is not None:
return {'source': 'direct', 'pressure_psf': float(direct_psf),
'reason': None}
if computed_psf is not None:
return {'source': 'computed', 'pressure_psf': float(computed_psf),
'reason': None}
if computed_reason:
return {'source': 'not_judged', 'pressure_psf': None,
'reason': str(computed_reason)}
return {'source': 'blank', 'pressure_psf': None,
'reason': ('no wind pressure: neither a direct entry nor a full set '
'of ASCE inputs (Vult, exposure, enclosure, version, '
'mean roof height, effective wind area) was supplied')}
def _wind_from(values, length_ft, bottom_ft, errors):
"""The `loading.wind` deck block -- {'mode': 'manual', 'zones': [...]}
-- or None when nothing usable was typed ("blank wind altogether = no
wind case", the owner's rule -- never an error).
Reads the `wind_zone` row table. A row with BOTH `pressure_psf` and
`suction_psf` blank is silently IGNORED -- an unfilled row (including
the single row `prefill` always seeds), not a mistake. A row with
either one given must carry a valid `from_elev_ft` / `to_elev_ft`
converting to positions inside `[0, length_ft]` (the same
elevation -> along-panel conversion `_restraints_from` /
`_conc_loads_from` use, `x_ft = elevation - bottom_ft`) and a positive
`width_ft`, or it is a hard error naming the row, appended to `errors`.
`wind_mode` itself is checked earlier, in `validate` -- by the time this
runs, 'From project config' has already been refused, so this never has
to decide between the two.
"""
values = values or {}
try:
length_val = float(length_ft) if length_ft is not None else None
except (TypeError, ValueError):
length_val = None
zones = []
clamp_notes = []
for row in rows_of(WIND_ZONE_TABLE, values, WIND_ZONE_COLS):
n = row['n']
pressure, p_err = _parse_num(row.get('pressure_psf'))
suction, s_err = _parse_num(row.get('suction_psf'))
if p_err:
errors.append('Wind zone {0}: pressure {1}'.format(n, p_err))
continue
if s_err:
errors.append('Wind zone {0}: suction {1}'.format(n, s_err))
continue
if pressure is None and suction is None:
continue # an unfilled row -- not an error
from_ft, ferr = _parse_feet(row.get('from_elev_ft'))
to_ft, terr = _parse_feet(row.get('to_elev_ft'))
width, werr = _parse_num(row.get('width_ft'))
if ferr or from_ft is None:
errors.append('Wind zone {0}: from-elevation {1}'.format(
n, ferr or 'is required'))
continue
if terr or to_ft is None:
errors.append('Wind zone {0}: to-elevation {1}'.format(
n, terr or 'is required'))
continue
if werr:
errors.append('Wind zone {0}: width {1}'.format(n, werr))
continue
if width is None or width <= 0:
errors.append(
'Wind zone {0}: width must be a positive number of feet'
.format(n))
continue
x0 = round(from_ft - bottom_ft, 4)
x1 = round(to_ft - bottom_ft, 4)
if x0 > x1:
x0, x1 = x1, x0
# Owner ruling, 2026-09-05: a prefilled zone spanning the model's
# own bottom-to-top elevation reads can overhang the panel's
# LENGTH by a fraction of an inch (rounding between the two
# separately-measured reads) -- refusing to run over that is a
# false error, not a real one. Up to 1" (1/12 ft) of overhang on
# either end is clamped into [0, length_ft] with a note; anything
# past that is still a hard error naming the row.
overhang_tol = 1.0 / 12.0
if length_val is not None:
lo_over = -x0 if x0 < 0 else 0.0
hi_over = (x1 - length_val) if x1 > length_val else 0.0
if lo_over > overhang_tol + 1e-6 or hi_over > overhang_tol + 1e-6:
errors.append(
'Wind zone {0}: {1:g}-{2:g} ft elevation converts to '
'{3:g}-{4:g} ft along the panel (bottom {5:g} ft), '
'which is outside the panel [0, {6:g}] ft'.format(
n, from_ft, to_ft, x0, x1, bottom_ft, length_val))
continue
if lo_over > 0 or hi_over > 0:
x0 = max(0.0, x0)
x1 = min(length_val, x1)
clamp_notes.append(
'Wind zone {0}: {1:g}-{2:g} ft elevation overhung the '
'panel by up to 1"; clamped to [{3:g}, {4:g}] ft'
.format(n, from_ft, to_ft, x0, x1))
label = (row.get('label') or '').strip() or 'W{0}'.format(n)
zones.append({
'label': label, 'x0_ft': x0, 'x1_ft': x1, 'width_ft': width,
'pressure_psf': pressure, 'suction_psf': suction,
})
if not zones:
return None
result = {'mode': 'manual', 'zones': zones}
if clamp_notes:
result['_clamp_note'] = '; '.join(clamp_notes)
return result
def _bottom_elevation(values):
"""(bottom_ft, note) -- this panel's own bottom elevation, or 0.0 with a
note saying the 0 is an assumption.
`bottom_elevation_ft` is read off the model by the button
(`pa_elevation.elevations_from_bbox`, for a piece modelled standing up in
place) and reaches `values` the same way every other model read does --
it is not a `FIELDS` entry (it lives on `pa_extents`'s own Concrete
Extents page), but `prefill`'s "every other read the button recovered
lands too" pass-through carries it into the shared `values` dict under
its own key regardless. Blank or unreadable -> `(0.0, note)`: every
elevation-to-position conversion on this form (`restraints`,
`conc_loads`) then measures from an assumed bottom of 0 ft rather than
refusing outright, and the note travels onto the deck so a reader can
tell the 0 is a default, never a real measured bottom.
"""
raw = (values or {}).get('bottom_elevation_ft')
text = (raw or '').strip() if raw is not None else ''
if not text:
return 0.0, ('no bottom_elevation_ft was read for this panel; '
'elevations below were converted to positions along '
'the panel assuming a bottom of 0 ft')
val, err = _parse_num(text)
if err or val is None:
return 0.0, ('bottom_elevation_ft {0!r} is not a number; elevations '
'below were converted to positions along the panel '
'assuming a bottom of 0 ft'.format(text))
return val, None
def _restraints_from(values, length_ft, bottom_ft, errors):
"""[{x_ft, type, elevation_ft, gravity, thermal, stiffness_kin}, ...],
or None -- the `restraint` row table (`RESTRAINT_TABLE` /
`RESTRAINT_COLS`), converted from ELEVATIONS (Eriksson's own convention)
to positions along the panel: `x_ft = elevation - bottom_ft` (`bottom_ft`
is this panel's own bottom elevation, from `_bottom_elevation` -- 0.0
with a note when the form does not carry one). The original elevation
rides along on the item as `elevation_ft` so the page can show both.
Zero PRESENT rows -> None, the deck's explicit unread placeholder --
restraints are still not required (2026-09-04's ruling was that the
table must stay EDITABLE, not that it must be filled). One or more
present rows -> at least two of them must carry a parseable elevation
landing inside `[0, length_ft]` once converted, or the whole panel is
refused, named by row -- a single typed restraint reads far more like a
mistake mid-entry than a real one-point restraint, and the in-place
solve cannot span fewer than two anyway.
"""
rows = rows_of(RESTRAINT_TABLE, values, RESTRAINT_COLS)
if not rows:
return None
try:
length_val = float(length_ft) if length_ft is not None else None
except (TypeError, ValueError):
length_val = None
out = []
ok = True
for row in rows:
n = row['n']
elev, err = _parse_feet(row.get('elevation_ft'))
if err or elev is None:
ok = False
errors.append('Restraint row {0}: elevation {1}'.format(
n, err or 'is required'))
continue
x_ft = round(elev - bottom_ft, 4)
if length_val is not None and not (-1e-6 <= x_ft <= length_val + 1e-6):
ok = False
errors.append(
'Restraint row {0}: elevation {1:g} ft converts to {2:g} ft '
'along the panel (bottom {3:g} ft), which is outside the '
'panel [0, {4:g}] ft -- fix the elevation or '
'bottom_elevation_ft rather than guessing which one is '
'right'.format(n, elev, x_ft, bottom_ft, length_val))
continue
stiffness, sk_err = _parse_num(row.get('stiffness_kin'))
if sk_err:
ok = False
errors.append('Restraint row {0}: stiffness {1}'.format(
n, sk_err))
continue
out.append({
'x_ft': x_ft, 'type': (row.get('type') or '').strip() or 'Pin',
'elevation_ft': elev,
'gravity': bool(_parse_bool(row.get('gravity'))[0]),
'thermal': bool(_parse_bool(row.get('thermal'))[0]),
'stiffness_kin': stiffness,
})
if not ok:
return None
if len(out) < 2:
errors.append(
'Restraints: at least two restraint rows with a parseable '
'elevation are needed once any are entered (got {0})'.format(
len(out)))
return None
return out
def _conc_loads_from(values, bottom_ft, errors):
"""[{x_ft, elevation_ft, offset_in, dead_lb, live_lb, label}, ...] or
None -- the `conc_load` row table (`CONC_LOAD_TABLE` / `CONC_LOAD_COLS`),
converted the same elevation -> position way `_restraints_from` /
`_wind_from` use. `dead_k` / `live_k` are KIPS, per Eriksson's own
column headers, converted to pounds (x1000) for the deck. `offset_in`
is carried on the row too -- `panelanalysis_schema.applied_axial` reads
it as the alias for `inplace.AppliedAxial.ecc_in`, the out-of-plane
eccentricity that turns this bearing load into a concentrated moment on
the in-place beam solve, the same "Offset" column Eriksson prints it
from.
Every PRESENT row must carry a parseable elevation, dead load and live
load (offset_in may be blank -- 0 downstream) or the whole panel is
refused, named by row: unlike restraints/wind, there is no "ignore this
one" case here -- a concentrated-load row the user added and left
broken now reaches the in-place solve, so it is never silently dropped.
Zero rows -> None (the caller emits `[]`, "no concentrated loads",
still a real, valid answer, never an error).
"""
rows = rows_of(CONC_LOAD_TABLE, values, CONC_LOAD_COLS)
if not rows:
return None
out = []
ok = True
for row in rows:
n = row['n']
elev, err = _parse_feet(row.get('elevation_ft'))
if err or elev is None:
ok = False
errors.append('Point load row {0}: elevation {1}'.format(
n, err or 'is required'))
continue
offset, err = _parse_num(row.get('offset_in'))
if err:
ok = False
errors.append('Point load row {0}: offset {1}'.format(n, err))
continue
dead, err = _parse_num(row.get('dead_k'))
if err or dead is None:
ok = False
errors.append('Point load row {0}: dead load {1}'.format(
n, err or 'is required'))
continue
live, err = _parse_num(row.get('live_k'))
if err or live is None:
ok = False
errors.append('Point load row {0}: live load {1}'.format(
n, err or 'is required'))
continue
label = (row.get('label') or '').strip() or 'P{0}'.format(n)
out.append({
'elevation_ft': elev, 'x_ft': round(elev - bottom_ft, 4),
'offset_in': offset, 'dead_lb': dead * 1000.0,
'live_lb': live * 1000.0, 'label': label,
})
if not ok:
return None
return out
# Architectural feet-inches, e.g. "11'-1 1/2\"", "11'-1\"", "11'". A plain
# decimal ('11.5') is tried first in `_parse_feet` and never reaches this.
_FEET_INCH_RE = re.compile(
r"^(-?\d+(?:\.\d+)?)\s*'\s*-?\s*"
r"(?:(\d+(?:\.\d+)?)?(?:\s+(\d+)/(\d+))?\s*\"?)?\s*$")
def _parse_feet(text):
"""(value, error) -- a number of feet, typed as a plain decimal
('11.5') or an architectural feet-inches string ("11'-1 1/2\"",
"11'-1\"", "11'"). Blank -> (None, None); anything else unparseable ->
(None, reason). No general feet-and-inches parser in `vibe` is reachable
from here: `ticketpage.parse_feet_text` is close but private to that
module, and `ticketextract.parse_dim` is CPython-only (the module's own
header: "pymupdf is not available to IronPython, so the Revit-side code
must not import this") -- so this is a small one written to the same
grammar rather than an import.
"""
if text is None:
return None, None
text = text.strip()
if not text:
return None, None
try:
return float(text), None
except (TypeError, ValueError):
pass
m = _FEET_INCH_RE.match(text)
if not m:
return None, 'is not a number of feet ({0!r})'.format(text)
feet = float(m.group(1))
inches = 0.0
if m.group(2):
inches += float(m.group(2))
if m.group(3) and m.group(4):
inches += float(m.group(3)) / float(m.group(4))
sign = -1.0 if feet < 0 else 1.0
return feet + sign * inches / 12.0, None
# --------------------------------------------------------- service (Stage 3)
_CRACKING_NOTE = (
"cracking_assumption ('Cracked' / 'Uncracked') is mapped onto the "
"engine's binary cracking_class_t (panellift.inplace.cracking_limit_"
"psi): Cracked -> Class T, 12*sqrt(f'c) (ACI 318-14 24.5.2); Uncracked "
"-> Class U, the stage's own tension_limit_sqrt_fc. This is the closest "
"honest reading of a binary form field onto ACI's three-way Class "
"U/T/C system -- this form offers no Class C (fully cracked) choice, so "
"that class is never produced.")
def _cracking_class_t(text):
"""(True / False / None, note_or_None) from `cracking_assumption`.
See `_CRACKING_NOTE` for the mapping and its honesty limits. Blank ->
(None, None) -- nothing to map. An unrecognised word is could-not-judge:
`(None, note)`, never a guessed side (the field stays free text; the
dropdown choices are offered, not enforced)."""
t = (text or '').strip().lower()
if not t:
return None, None
if t.startswith('uncrack'):
return False, _CRACKING_NOTE
if t.startswith('crack'):
return True, _CRACKING_NOTE
return None, ('cracking_assumption {0!r} is neither Cracked nor '
'Uncracked; cracking_class_t left unset. {1}'
.format(text, _CRACKING_NOTE))
_DEFLECTION_RATIO_RE = re.compile(r'^[Ll]\s*/\s*(\d+(?:\.\d+)?)\s*$')
def _parse_deflection_ratio(text):
"""(n, error) -- the n in L/n, from 'L/150', 'L / 150' or a bare '150'.
Blank -> (None, None)."""
if text is None:
return None, None
text = text.strip()
if not text:
return None, None
m = _DEFLECTION_RATIO_RE.match(text)
if m:
n = float(m.group(1))
else:
try:
n = float(text)
except (TypeError, ValueError):
return None, ("is not a deflection limit like 'L/150' "
'({0!r})'.format(text))
if n <= 0:
return None, 'deflection limit ratio must be positive ({0!r})'.format(
text)
return n, None
def _service_from(values):
"""The `loading.service` deck block -- {deflection_limit_ratio,
initial_bow_in, axial_eccentricity_in, cracking_class_t}, or None.
Reads four form fields that are not a `FIELDS` entry of THIS module (they
live on `pa_criteria`'s / `pa_structural`'s own pages, under their own
dotted keys, reaching the shared `values` dict the same way `bottom_
elevation_ft` does): `deflection_limit` ('L/150' -> ratio 150),
`design_criteria.deflection_add_bow` + `.deflection_additional_bow_in`
(the bow is 0 unless the flag reads Yes), `load_opt_selfweight_ecc_in`,
and `cracking_assumption` (-> `cracking_class_t`, see `_cracking_
class_t`). None when NONE of the four source fields carry any text --
`build_deck` then keeps its explicit unread placeholder; every one of
these four is seeded with a generic default by `pa_defaults.
apply_defaults` on a real prefilled panel (`deflection_limit` -> 'L/150',
the rest -> 'No' / '0' / 'Uncracked'), so a live panel's deck carries a
real `loading.service` block, not the placeholder -- exactly the gap
this was written to close (see the module CLAUDE.md task: "the page
says NOT JUDGED although the form had L/150").
A source field that will not parse never blocks the Run button (none of
the four is `required`); it is dropped to the honest could-not-judge
default (0, or the deflection check staying NOT JUDGED) with the reason
folded into the block's own `_note`.
"""
values = values or {}
raw_defl = (values.get('deflection_limit') or '').strip()
raw_bow_flag = (values.get('design_criteria.deflection_add_bow') or ''
).strip()
raw_ecc = (values.get('load_opt_selfweight_ecc_in') or '').strip()
raw_crack = (values.get('cracking_assumption') or '').strip()
if not (raw_defl or raw_bow_flag or raw_ecc or raw_crack):
return None
out = {}
notes = []
n, err = _parse_deflection_ratio(raw_defl)
if err:
notes.append('deflection_limit {0}; the deflection limit check '
'stays NOT JUDGED'.format(err))
elif n is not None:
out['deflection_limit_ratio'] = n
add_bow = raw_bow_flag.lower() in ('yes', 'y', 'true', '1')
bow = 0.0
if add_bow:
bow_val, err = _parse_num(values.get(
'design_criteria.deflection_additional_bow_in'))
if err:
notes.append('design_criteria.deflection_additional_bow_in {0}; '
'initial bow taken as 0 in'.format(err))
elif bow_val is not None:
bow = bow_val
out['initial_bow_in'] = bow
ecc, err = _parse_num(raw_ecc)
if err:
notes.append('load_opt_selfweight_ecc_in {0}; axial eccentricity '
'taken as 0 in'.format(err))
ecc = 0.0
out['axial_eccentricity_in'] = ecc if ecc is not None else 0.0
class_t, note = _cracking_class_t(raw_crack)
if class_t is not None:
out['cracking_class_t'] = class_t
if note:
out['_cracking_note'] = note
if notes:
out['_note'] = '; '.join(notes)
return out
# ---------------------------------------------------------- load combinations
_COMBO_CITE = {'strength': 'ACI 318-19 5.3.1', 'service': 'ASCE 7-22 2.4'}
_COMBO_PREFIX = {'strength': 'LC', 'service': 'SC'}
def _combo_factor_fmt(v):
"""A load-combination factor as the reference tool prints it -- '1.0',
never '1' -- unlike `_fmt`, which trims a whole number to an integer
string for a display field."""
if v == int(v):
return '{0:.1f}'.format(v)
s = ('%.4f' % v).rstrip('0')
if s.endswith('.'):
s += '0'
return s
def _combo_label(factors):
"""'1.2D + 1.6L + 0.5R + 1.0T + 1.6H' -- the printed formula for one
combination's factors, in `LOAD_COMBO_FACTORS` order, only the nonzero
terms (matching `panellift.inplace`'s own combination labels)."""
parts = []
for col in LOAD_COMBO_FACTORS:
v = factors.get(col)
if not v:
continue
parts.append('{0}{1}'.format(_combo_factor_fmt(v), col))
return ' + '.join(parts) if parts else '0'
def _combinations_for_deck(combo_rows):
"""[{name, label, factors, kind, cite}, ...] -- `panelanalysis_schema.
_parse_combinations`'s own shape, built from `load_combinations(values)`'s
ticked rows. `name` is 'LC2' / 'SC2' (the table's own row id, prefixed);
`label` is the printed formula (`_combo_label`); `kind` is the table
('strength' / 'service'), the same word `inplace.Combo.kind` reads to
split strength from service; `cite` is the generic code clause (ACI
318-19 5.3.1 for strength, ASCE 7-22 2.4 for service) -- public code
text, not a job's own citation.
Only called from `build_deck` on `parsed['load_combinations']`, which
`validate` has already refused to pass through with any `problem`
(a row with an unreadable factor is a hard validation error -- see
`validate`'s own docstring) -- the `problem` guard below is still kept,
defensively, rather than trusting that invariant blindly.
"""
out = []
for row in combo_rows or []:
if row.get('problem'):
continue
table = row.get('table') or 'strength'
factors = dict(row.get('factors') or {})
out.append({
'name': '{0}{1}'.format(_COMBO_PREFIX.get(table, 'LC'),
row.get('id')),
'label': _combo_label(factors),
'factors': factors,
'kind': table,
'cite': _COMBO_CITE.get(table, ''),
})
return out
# ------------------------------------------------------------ lifter rows
def _parse_xfrac_lines(text):
"""[(x_ft, frac), ...] off one `'x_ft @ frac'`-per-line lifter text --
the SAME grammar `vibe.pa_lifters.parse_lifter_lines` /
`vibe.pa_sketch_handling._parse_lifter_lines` read, duplicated here
(rather than imported -- this module sits BELOW those two, never above
them; see this module's own module docstring) purely to seed the
`face_lifter`/`edge_lifter` tables' PREFILL rows from the model's
legacy `face_lifters`/`edge_lifters`/`top_edge_lifters`/
`side_edge_lifters` reads. A line that will not parse is simply
skipped -- this is a best-effort PREFILL, never a validated field, so a
partial read still seeds what it can rather than seeding nothing.
"""
out = []
if not text:
return out
for line in str(text).splitlines():
line = line.strip()
if not line:
continue
parts = [p.strip() for p in line.split('@')]
if len(parts) != 2:
continue
x_ft, _e1 = _parse_num(parts[0])
frac, _e2 = _parse_num(parts[1])
if x_ft is None or frac is None:
continue
out.append((x_ft, frac))
return out
def _edge_lifter_row_from_legacy(x_ft, frac, length_ft, width_ft, kind_hint):
"""(edge, position_ft) for one legacy edge-lifter line, seeding the
`edge_lifter` table's PREFILL row -- a small local mirror of
`vibe.pa_sketch_handling`'s own end/side classification (`_end_side_
from_x` / `_classify_from_geometry`), not imported for the same
"this module sits below that one" reason `_parse_xfrac_lines` gives.
`kind_hint`: `'end'` for a `top_edge_lifters` line (files BOTH ends,
per that field's own read -- side chosen by `x_ft`), `'side'` for a
`side_edge_lifters` line (side chosen by `frac` alone), `None` to
classify `edge_lifters` by geometry (an end within 0.5 ft of either
panel end, else a side). `position_ft` is `None` when an END lifter's
position cannot be converted (`width_ft` missing) -- the caller must
skip seeding that row rather than writing a blank position.
"""
end_tol = 0.5
is_end = kind_hint == 'end'
if kind_hint is None:
is_end = (abs(x_ft - 0.0) <= end_tol or
(length_ft and abs(x_ft - (length_ft or 0.0)) <= end_tol))
if is_end:
if length_ft and abs(x_ft - length_ft) <= end_tol and \
abs(x_ft - 0.0) > end_tol:
edge = 'Right'
else:
edge = 'Left'
position_ft = round(frac * width_ft, 4) if width_ft else None
else:
edge = 'Bottom' if frac <= 0.5 else 'Top'
position_ft = round(x_ft, 4)
return edge, position_ft
# ------------------------------------------------------------------ openings
def _opening_rows_from_table(values):
"""[{'type', 'pos_along', 'pos_along_from', 'length', 'pos_cross',
'pos_cross_from', 'width', 'pos_vert', 'height', 'label'}, ...] built
from the `OPENING_TABLE` row table's PRESENT rows -- the exact shape
`_openings_for_deck` (and `panelsketch`) already read, so replacing the
old free-text/passthrough `openings_rows` source with this table needed
no change to either reader.
The table's own columns are already ABSOLUTE positions (`x_ft` from the
panel's start, `y_ft` from its bottom -- see `OPENING_TABLE`'s own
comment), so `pos_along_from` is always `'start'` and, for a Reveal,
`pos_cross_from` is always `'Face'` -- there is no `_from` word left to
interpret. `depth_in` (inches) becomes `width` (feet, the through-
thickness extent `_openings_for_deck` reads for a Reveal) only when the
row is a Reveal and carries a readable depth; blank/unreadable leaves
`width` `None` (an Opening's own "goes full depth" answer, and a Reveal
with no depth typed yet is "not judged", never a fabricated 0).
"""
out = []
for row in rows_of(OPENING_TABLE, values, OPENING_COLS):
rtype = (row.get('type') or '').strip() or 'Opening'
is_reveal = rtype.strip().lower() == 'reveal'
width = None
if is_reveal:
depth_in, derr = _parse_num(row.get('depth_in'))
if not derr and depth_in is not None:
width = depth_in / 12.0
out.append({
'type': rtype,
'pos_along': row.get('x_ft') or None,
'pos_along_from': 'start',
'length': row.get('length_ft') or None,
'pos_cross': 0.0 if is_reveal else None,
'pos_cross_from': 'Face' if is_reveal else None,
'width': width,
'pos_vert': row.get('y_ft') or None,
'height': row.get('width_ft') or None,
'label': '{0} {1}'.format(rtype or 'Opening', row['n']),
})
return out
def _openings_for_deck(rows, length_ft, width_ft):
"""([blockout, ...], [recess, ...], notes) from the form's
`openings_rows` -- see the module docstring's "HOW OPENINGS REACH
`blockouts` / `recesses`" note for the axis mapping (the same judgement
call `panelsketch._parse_opening_row` already makes and flags) and why
each type maps where it does.
A row missing `pos_along`/`length` (cannot place along the length axis
at all) or `pos_vert`/`height` (cannot place along the width axis) is
skipped, named by index, in `notes` -- never guessed. `width_ft` is
accepted for symmetry with `length_ft` and to keep this function's
signature stable if a range check on the width axis is added later; it
is not read yet (`y0`/`width` are carried as typed, unchecked against
the panel's own width the way `_restraints_from` checks length).
"""
blockouts = []
recesses = []
notes = []
solid_zone_n = 0
try:
length_val = float(length_ft) if length_ft is not None else None
except (TypeError, ValueError):
length_val = None
for i, row in enumerate(rows or [], 1):
if not isinstance(row, dict):
notes.append('row {0}: not a recognised opening row'.format(i))
continue
rtype = str(row.get('type') or '').strip()
rtype_l = rtype.lower()
label = str(row.get('label') or '').strip() or '{0} {1}'.format(
rtype or 'Opening', i)
if rtype_l == 'solid zone':
solid_zone_n += 1
continue
pos_along = row.get('pos_along')
length = row.get('length')
if pos_along is None or length is None:
notes.append('row {0} ({1}): no along-length position/extent -- '
'not placed'.format(i, label))
continue
pos_vert = row.get('pos_vert')
height = row.get('height')
if pos_vert is None or height is None:
notes.append("row {0} ({1}): no vertical position/extent (the "
"panel's width axis) -- not placed".format(i, label))
continue
try:
pos_along_v = float(pos_along)
length_v = float(length)
pos_vert_v = float(pos_vert)
height_v = float(height)
except (TypeError, ValueError):
notes.append('row {0} ({1}): a position or extent is not a '
'number -- not placed'.format(i, label))
continue
along_from = str(row.get('pos_along_from') or 'start').strip().lower()
if along_from == 'end' and length_val is not None:
x0 = length_val - pos_along_v - length_v
else:
x0 = pos_along_v
y0 = pos_vert_v
if rtype_l == 'opening':
blockouts.append({
'name': label, 'x0': round(x0, 4), 'y0': round(y0, 4),
'length': round(length_v, 4), 'width': round(height_v, 4),
'depth_in': None,
})
elif rtype_l == 'reveal':
depth_in = None
cross_width = row.get('width')
if cross_width is not None:
try:
depth_in = round(float(cross_width) * 12.0, 3)
except (TypeError, ValueError):
depth_in = None
recesses.append({
'name': label, 'x0': round(x0, 4), 'y0': round(y0, 4),
'length': round(length_v, 4), 'width': round(height_v, 4),
'depth_in': depth_in, 'concrete_in': None,
})
else:
notes.append("row {0} ({1}): type {2!r} is not Opening / Reveal "
"/ Solid Zone -- not placed".format(i, label, rtype))
if solid_zone_n:
notes.append(
'{0} solid-zone row(s) on the form have no panellift shape '
'(neither a hole nor a blind pocket) -- left out of both '
'blockouts and recesses'.format(solid_zone_n))
return blockouts, recesses, notes
# ------------------------------------------------------------ stage matrix
# The four Handling stages, in Eriksson's own order (p.6). A COLUMN each --
# not a flat list of "stripping multiplier" / "transport multiplier" /
# "erection multiplier" fields the way this form used to carry them, which
# had no way to add "Secondary Erection" without a fourth ad-hoc key and no
# way to show a reviewer that the four stages are the same set of questions
# asked four times. `strip_mult` / `transport_mult` / `erection_mult` /
# `sling_angle` are GONE from `FIELDS` -- migrated into this matrix, not left
# sitting beside it (the owner's instruction was explicit that they migrate).
STAGES = ('Stripping', 'Transportation', 'Primary Erection',
'Secondary Erection')
# One ROW per option, Eriksson's own p.6/p.4-of-the-handling-run order (an
# actual Eriksson output PDF confirmed this order and column set after the
# first cut of this module -- no text from that file is quoted here, only
# its column structure and a handful of generic example VALUES, offered
# below as `choices`, never as a default). Each entry:
# (opt_key, label, symbol, help, kind, drives, choices)
# `kind` is 'bool' | 'num' | 'text' (a fourth kind, alongside FIELDS' set,
# because a stage-matrix cell is never an 'openings'/'strands'/'supports'
# block). Only `include` and `load_multiplier` are `drives='solve'` -- the
# only two the Stage-0 self-weight solve reads (as the stripping / transport /
# erection multipliers it always read, now sourced from here instead).
# `choices`, where not None, is a tuple of values SEEN in a real Eriksson
# output for that row -- offered so the button can show them as a dropdown's
# suggestions, never enforced (the field stays free text) and never defaulted
# (owner's instruction: "do NOT make any of them a default").
STAGE_OPTIONS = (
('include', 'Include this stage?', '',
'Whether this handling stage applies to this panel at all. Leave '
'blank (or "yes") to include it; type "no" to skip it -- anything you '
'already typed in this stage\'s row is kept, just no longer required '
'or used.', 'bool', 'solve', None),
('load_multiplier', 'Load multiplier', u'× self-weight',
"How much heavier than its own static weight the panel is treated as "
'during this stage (impact / dynamic effects). Blank uses the '
'built-in default of 1.0.', 'num', 'solve', None),
('lift_type', 'Lift type', '',
'How the panel is picked up for this stage (vacuum, strongback, '
'choker, ...). Not yet used by the solve.', 'text', 'stage3',
('Static Lift', 'Rolling Block')),
('concrete_strength', 'Concrete strength required', u"f′ci or f′c (psi)",
'The concrete strength this stage assumes the panel has already '
'reached. Not yet used by the solve.', 'text', 'stage3',
('Initial', 'In-Place')),
('stress_limits', 'Stress limits used', '',
'Which stress-limit set (handling vs. in-place) governs this stage. '
'Not yet used by the solve.', 'text', 'stage3',
('Handling', 'In-Place')),
('rigging', 'Rigging configuration', '',
'How the lifting rigging is arranged for this stage (bridle, '
'spreader bar, ...). Not yet used by the solve.', 'text', 'stage3',
('Auto', 'Two-point', 'Four-point', 'Eight-point')),
('sling_angle', 'Sling angle', u'°',
'The angle of the lifting slings from horizontal for this stage. '
'Not yet used by the solve.', 'num', 'stage3', None),
('ground_reaction', 'Ground reaction assumed?', '',
'Whether the panel is assumed to still be resting on the ground / '
'trailer during part of this stage. Not yet used by the solve.',
'bool', 'stage3', None),
('min_tilt_deg', 'Minimum tilt angle', u'°',
'The panel angle from flat this stage starts at. Eriksson prints '
'minimum and maximum tilt as two numbers in one cell; this form '
'splits them into two rows instead so each can be typed and checked '
'on its own. Not yet used by the solve.', 'num', 'stage3', None),
('max_tilt_deg', 'Maximum tilt angle', u'°',
'The panel angle from flat this stage ends at (see the minimum-tilt '
'row above for why this is a separate row). Not yet used by the '
'solve.', 'num', 'stage3', None),
('top_crane_carries', 'Top crane line carries weight?', '',
'Whether a second (top) crane line is sharing the lift for this '
'stage. Not yet used by the solve.', 'bool', 'stage3', None),
)
# Which stage's `load_multiplier` feeds which existing `handling.*`
# deck key -- unchanged from the flat fields it replaces. "Secondary
# Erection" has no deck slot yet (Eriksson has it, the Stage-0 solve does
# not), so its multiplier stays out of the deck exactly like every other
# stage3 field -- collected, never fabricated into a result.
_STAGE_DECK_MULT_KEY = {
'Stripping': 'stripping_multiplier',
'Transportation': 'transport_multiplier',
'Primary Erection': 'erection_multiplier',
}
def _stage_slug(stage_name):
return stage_name.lower().replace(' ', '_')
def stage_field_key(stage, opt_key):
"""The `values` dict key for one matrix cell, e.g.
'stage.primary_erection.load_multiplier'.
"""
return 'stage.{0}.{1}'.format(_stage_slug(stage), opt_key)
def stage_field_keys():
"""Every matrix cell's `values` key, stage-major order."""
return [stage_field_key(stage, opt[0])
for stage in STAGES for opt in STAGE_OPTIONS]
def stage_matrix():
"""The Handling grid the button renders: columns = stages, rows = options.
Returns:
{'stages': ['Stripping', 'Transportation', 'Primary Erection',
'Secondary Erection'],
'rows': [{'opt_key': 'include', 'label': ..., 'symbol': ...,
'help': ..., 'kind': 'bool', 'drives': 'solve',
'choices': None,
'cells': {'Stripping': 'stage.stripping.include', ...}},
...]}
One dict per option row; `cells` maps each stage name to the exact
`values` key that stage's cell reads/writes, via `stage_field_key`, so
the button never has to reconstruct the key format itself. `choices`,
where not None, is a tuple of example values seen in a real Eriksson
printout for that row (e.g. `('Static Lift', 'Rolling Block')` for
`lift_type`) -- a hint for a dropdown, never enforced and never a
default. This is a real GRID, not `sections()`'s per-field list --
Eriksson draws it as a matrix precisely because the same eleven
questions are asked once per stage, and a flat list would repeat every
label four times with no way to see the shape. The button renders this
inside the 'Handling' section, alongside (not replacing) the groups
`sections()` returns for it ('Lifter & Dunnage Positions' and the two
ungrouped fields).
"""
rows = []
for opt_key, label, symbol, help_text, kind, drives, choices in \
STAGE_OPTIONS:
cells = dict((stage, stage_field_key(stage, opt_key))
for stage in STAGES)
rows.append({'opt_key': opt_key, 'label': label, 'symbol': symbol,
'help': help_text, 'kind': kind, 'drives': drives,
'choices': choices, 'cells': cells})
return {'stages': list(STAGES), 'rows': rows}
def _parse_stage_matrix(values, errors):
"""{stage: {opt_key: value}} -- the matrix's parsed form.
`include` parses first per stage; a stage is EXCLUDED only when it reads
explicit "no" (`False`) -- blank/"yes" defaults to included, matching
every stage's old always-on behaviour before this form had an include
switch at all. Once a stage's include state is known:
* an excluded stage's other fields NEVER block the Run button, no
matter what `drives` says -- the owner's rule ("A stage whose
include is off must not gate the Run button on its other fields").
Whatever the user typed is KEPT in the parsed cell (the raw text, if
it did not parse) rather than dropped, so nothing they entered
vanishes just because they toggled the stage off.
* an included stage's `drives='solve'` field (today: only
`load_multiplier`) is validated exactly like any other solve field --
garbage blocks the run, named by stage and label.
* a `stage3` field never blocks regardless of include, same as every
other stage3 field on the form.
"""
matrix = {}
include_label = STAGE_OPTIONS[0][1]
for stage in STAGES:
row = {}
include_key = stage_field_key(stage, 'include')
included, err = _parse_bool(values.get(include_key))
if err:
errors.append('{0} — {1} {2}'.format(stage, include_label, err))
row['include'] = included
included_effective = included is not False
for opt_key, label, _symbol, _help, kind, drives, _choices in \
STAGE_OPTIONS:
if opt_key == 'include':
continue
fkey = stage_field_key(stage, opt_key)
raw = values.get(fkey)
if kind == 'num':
val, err = _parse_num(raw)
elif kind == 'bool':
val, err = _parse_bool(raw)
else:
text = (raw or '').strip()
val, err = (text or None), None
if err and included_effective and drives == 'solve':
errors.append('{0} — {1} {2}'.format(stage, label, err))
row[opt_key] = None
elif err:
# Excluded stage, or a stage3 option: never blocks. Keep
# whatever was typed rather than silently dropping it.
row[opt_key] = raw
else:
row[opt_key] = val
matrix[stage] = row
return matrix
# --------------------------------------------------------------- validation
def _model_strand_x_ft(values, total_count, width_ft):
"""[y_ft, ...] (length `total_count`), or None to fall back to spread
evenly -- `values['strand_x_in']` converted to feet when it is a real
list of exactly `total_count` numbers, honouring `strand_rail_location`:
'Right edge' MIRRORS (`width_ft - x_in/12`), 'Centerline' OFFSETS by
half the width (`width_ft/2 + x_in/12`), anything else (including
'Left edge' / blank) uses `x_in/12` directly.
ORDER MATTERS and is never re-derived here: `strand_x_in` is one entry
per strand, in the SAME order `pa_reinf.strand_prefill` emits it --
strand ROWS sorted by depth ascending, then, within a row, strands
sorted by ascending x (see that function's own docstring and its
`for depth in sorted(groups.keys())` / `sorted(members, key=...y...)`
loops). `panelanalysisform.prefill` seeds the `strand_row` table off
that SAME `strand_rows` read, in that same order, so `rows_of
(STRAND_ROW_TABLE, ...)` -- what `_prestress_from` iterates below --
already carries the identical row order the strand_x_in list assumes,
as long as the table has not been reordered by hand since prefill. This
function does not re-sort or re-match by depth; it trusts that order
and simply consumes `strand_x_in` sequentially as `_prestress_from`
flattens rows into individual strands, the same "row by row, in order"
discipline the old even-spread formula always used.
A wrong-length (or non-list) `strand_x_in` is not a partial win -- None,
same as absent, so the caller's `x_basis` reads 'even spread' rather
than silently mixing real and guessed positions.
"""
raw = values.get('strand_x_in')
if not isinstance(raw, (list, tuple)) or len(raw) != total_count:
return None
xs = []
for v in raw:
if isinstance(v, bool):
return None
if isinstance(v, (int, float)):
xs.append(float(v))
continue
try:
xs.append(float(str(v).strip()))
except (TypeError, ValueError):
return None
rail = (values.get('strand_rail_location') or '').strip().lower()
if 'right' in rail:
return [width_ft - (x / 12.0) for x in xs]
if 'center' in rail:
return [width_ft / 2.0 + (x / 12.0) for x in xs]
return [x / 12.0 for x in xs]
def _prestress_from(values, width_ft, errors):
"""(prestress_block_or_None, strand_rows).
All prestress fields blank AND no `strand_row` table rows present ->
(None, []) -- a real "no prestress" answer. Otherwise at least one
strand-group ROW must be present and valid, every row must carry its
own initial pull, and the default diameter (`ps_dia_in`) is required
only for a row that gives no `dia_in` of its own -- or it is an error
named field by field / row by row.
`ps_count` (owner, 2026-09-07, issue #23 item 9b): once rows exist the
count IS the sum of the rows -- the page shows it read-only -- so it is
not compared against them here any more; a stale typed value cannot
block a run through a field the user can no longer see. With no rows at
all a filled `ps_count` still means "prestress described" and the
missing rows are the error.
`ps_pull_lb` (same ruling, item 9e) is NOT read here: the rows are the
only source of initial pull. A row with a blank pull is an error naming
the row, never silently filled from a scalar that is no longer on the
page.
Each strand's position across the panel WIDTH (`strands[].y`, the
prestress block's `x_basis`) is REAL when `values['strand_x_in']`
carries one entry per strand -- 2026-09-04, `_model_strand_x_ft` --
and falls back to spread evenly across the width at that group's own
depth only when it does not (`x_basis`: 'model' | 'even spread', named
on the `prestress` dict so a caller can tell the two apart rather than
the deck silently looking idealised either way).
Reads the `strand_row` table (`STRAND_ROW_TABLE` / `STRAND_ROW_COLS`) --
2026-09-04, replacing the old `ps_rows` free-text field (owner: "rebar
怎么可能是打字上去?而不是进入 model 的?"). Each row's own `dia_in` /
`pull_lb`, when given, OVERRIDE the form's single default for that group
only -- the same per-row-override idea the old `ps_rows` '@ pull_pct'
grammar carried, now a directly-editable absolute force per row instead
of a percentage computed off fpu.
"""
rows = rows_of(STRAND_ROW_TABLE, values, STRAND_ROW_COLS)
filled = [k for k in _PRESTRESS_KEYS if (values.get(k) or '').strip()]
if not filled and not rows:
return None, []
# Owner ruling, 2026-09-05: the page's single default diameter/pull are
# required only when at least one PRESENT strand-group row has NOT
# overridden it with its own -- a schedule where every row already
# carries a real diameter and pull (the ordinary case for a model
# read: `reads['strand_rows']` supplies both per group) needs no
# separate default typed in on top, and refusing to run for want of
# one was blocking a perfectly complete panel.
rows_missing_dia = [r for r in rows if not (r.get('dia_in') or '').strip()]
dia_required = bool(rows_missing_dia) or not rows
default_dia_text = values.get('ps_dia_in')
if (default_dia_text or '').strip() == '':
if dia_required:
errors.append(
'{0} is required once any prestress field is filled'
.format(display_label('ps_dia_in')))
default_area = None
else:
default_area, err = strand_area_in2(default_dia_text)
if err:
errors.append(err)
loss, err = _parse_num(values.get('ps_loss_pct'))
if err:
errors.append('{0} {1}'.format(display_label('ps_loss_pct'), err))
loss = None
fpu, err = _parse_num(values.get('ps_fpu_ksi'))
if err:
errors.append('{0} {1}'.format(display_label('ps_fpu_ksi'), err))
fpu = None
if not rows:
errors.append(
'Strand groups: at least one strand-group row is required once '
'any prestress field is filled')
return None, []
parsed_rows = []
rows_ok = True
for row in rows:
n = row['n']
count_val, err = _parse_int(row.get('count'))
if err or count_val is None:
rows_ok = False
errors.append('Strand group row {0}: count {1}'.format(
n, err or 'is required'))
continue
depth, err = _parse_num(row.get('depth_in'))
if err or depth is None:
rows_ok = False
errors.append('Strand group row {0}: depth {1}'.format(
n, err or 'is required'))
continue
row_dia_text = (row.get('dia_in') or '').strip() or default_dia_text
row_pull_text = (row.get('pull_lb') or '').strip()
if not row_pull_text:
rows_ok = False
errors.append('Strand group row {0}: initial pull is required '
'(every strand row carries its own)'.format(n))
continue
parsed_rows.append({
'n': n, 'count': count_val, 'depth_in': depth,
'dia_in': row_dia_text, 'pull_lb': row_pull_text,
})
if not rows_ok:
return None, []
total_count = sum(r['count'] for r in parsed_rows)
# `default_area` is only actually NEEDED for a row that did not supply
# its own diameter -- when every row did (the ordinary model-read
# case), a blank scalar default must not stop the strands block from
# being built at all (see the `dia_required` note above; this is the
# build-side half of the same fix).
if (default_area is None and rows_missing_dia) or not width_ft:
return None, parsed_rows
# One nominal diameter for the whole prestress block (`panellift`'s
# `Prestress.diameter_in` is a single number): the scalar default when
# it names a size, else the first row's own -- by `strand_dia_nominal_in`
# so a fraction spelling resolves instead of falling to 0.5.
default_dia = strand_dia_nominal_in(default_dia_text)
if default_dia is None:
for row in parsed_rows:
default_dia = strand_dia_nominal_in(row['dia_in'])
if default_dia is not None:
break
fpu_val = fpu if fpu is not None else DEFAULT_FPU_KSI
model_x_ft = _model_strand_x_ft(values, total_count, width_ft)
x_basis = 'model' if model_x_ft is not None else 'even spread'
strands = []
idx = 0
for row in parsed_rows:
row_area = default_area
if row['dia_in'] and row['dia_in'] != default_dia_text:
area, err = strand_area_in2(row['dia_in'])
if err:
errors.append('Strand group row {0}: {1}'.format(
row['n'], err))
return None, parsed_rows
row_area = area
pull_val, err = _parse_num(row['pull_lb'])
if err or pull_val is None:
errors.append('Strand group row {0}: pull {1}'.format(
row['n'], err or 'is not a number'))
return None, parsed_rows
if pull_val <= 0:
errors.append('Strand group row {0}: pull must be positive (the '
'jacking force per strand)'.format(row['n']))
return None, parsed_rows
row_pull = pull_val
for _j in range(row['count']):
idx += 1
if model_x_ft is not None:
y = model_x_ft[idx - 1]
else:
y = (idx - 0.5) / float(total_count) * width_ft
strands.append({
'y': round(y, 4), 'z_in': row['depth_in'],
'area_in2': row_area, 'initial_pull_lb': row_pull,
'label': 'S{0}'.format(idx)})
prestress = {
'diameter_in': default_dia if default_dia is not None else 0.5,
'loss_pct': loss if loss is not None else DEFAULT_LOSS_PCT,
'fpu_ksi': fpu_val,
'x_basis': x_basis,
'strands': strands,
'rows': parsed_rows,
}
return prestress, parsed_rows
_MILD_AS_TOKEN_RE = re.compile(r'(?:(\d+)\s*[xX]\s*)?#\s*(\d{1,2})')
def _parse_mild_as_summary(text):
"""[{'size': '#N', 'count': n_or_None}, ...] -- a best-effort read of a
comma-joined mild-reinforcement summary string like
'8 x #3 (MC_S03), 24 x #4 (MC_S04)' into `mild_bar` row seeds.
A token with no readable bar size contributes no row -- today's actual
button read is just bare mark names ('MC_S03, MC_S04'), which has no
size in it at all, so this seeds nothing from that shape yet. This is a
pre-fill ACCELERATOR, never a parser the user depends on: the row table
exists precisely so a size/count this cannot read is typed by hand
instead, the same "pre-fill what is readable, blank the rest" rule
every other field on this form follows.
"""
rows = []
if not text:
return rows
for token in str(text).split(','):
m = _MILD_AS_TOKEN_RE.search(token)
if not m:
continue
count_txt, size_txt = m.groups()
size = '#{0}'.format(size_txt)
if size not in MILD_BAR_AREA_IN2:
continue
count = None
if count_txt:
try:
count = int(count_txt)
except (TypeError, ValueError):
count = None
rows.append({'size': size, 'count': count})
return rows
def _mild_bars_from(values, length_ft):
"""([{'size','count','area_in2','depth_in','direction','from_ft',
'to_ft'}, ...], notes) -- the `mild_bar` row table
(`MILD_BAR_TABLE` / `MILD_BAR_COLS`), for `reinforcement.mild_bars`.
NEVER blocks the Run button -- mild reinforcement is a Stage-3 input the
in-place engine reads later, the same standing every other stage3 field
on this form has. A row that will not parse is skipped and named in
`notes` instead of refused. `area_in2` is `count` times the ASTM
per-bar area for `size` (`MILD_BAR_AREA_IN2`); an unrecognised size
skips the row, named. A row with a blank depth is KEPT --
`depth_in: None`, never a fabricated cover -- and folded into `notes`
so the deck can say it cannot yet enter the capacity check for that row.
"""
rows = rows_of(MILD_BAR_TABLE, values, MILD_BAR_COLS)
out = []
notes = []
try:
length_val = float(length_ft) if length_ft is not None else None
except (TypeError, ValueError):
length_val = None
for row in rows:
n = row['n']
size = (row.get('size') or '').strip()
if not size:
notes.append('mild bar row {0}: no size given -- skipped'
.format(n))
continue
key = size if size.startswith('#') else '#' + size
if key not in MILD_BAR_AREA_IN2:
notes.append(
'mild bar row {0}: {1!r} is not a recognised bar size -- '
'skipped'.format(n, size))
continue
count, err = _parse_int(row.get('count'))
if err or count is None:
notes.append('mild bar row {0}: count {1} -- skipped'.format(
n, err or 'is required'))
continue
depth, err = _parse_num(row.get('depth_in'))
if err:
notes.append('mild bar row {0}: depth {1} -- depth left blank'
.format(n, err))
depth = None
direction = (row.get('direction') or '').strip() or 'Longitudinal'
from_val, ferr = _parse_num(row.get('from_ft'))
if ferr or from_val is None:
from_val = 0.0
to_val, terr = _parse_num(row.get('to_ft'))
if terr or to_val is None:
to_val = length_val if length_val is not None else from_val
if depth is None:
notes.append(
"mild bar row {0} ({1}): no depth given -- cannot enter the "
'capacity check until a depth is typed'.format(n, key))
out.append({
'size': key, 'count': count,
'area_in2': round(count * MILD_BAR_AREA_IN2[key], 4),
'depth_in': depth, 'direction': direction,
'from_ft': from_val, 'to_ft': to_val,
})
return out, notes
def validate(values):
"""(errors, parsed). `errors` is [] when the panel may run.
Every required field left blank or unreadable is named in `errors`, and a
solve-driving field the user typed garbage into is named too; while
`errors` is non-empty `parsed` is None and the analysis must not run --
the completeness gate the Run Analysis button hangs on. The <2 supports
case is one of those errors ("an unsupported beam must never analyse as
OK"), surfaced here rather than as a traceback out of panellift. A stage3
Eriksson input (not yet used) never blocks the run, and neither does any
field belonging to a Handling stage the user has switched off.
`parsed`, on success, carries every `drives='solve'` field's parsed
value, plus:
'supports' -- [float, ...] (>= 2)
'prestress' -- the strands block, or None (see `_prestress_from`)
'strand_rows' -- [{'n', 'count', 'depth_in', 'dia_in', 'pull_lb'},
...] the `strand_row` table's PRESENT rows,
whether or not a full prestress block was
buildable (`dia_in` / `pull_lb` are the row's own
override text, or the form default when the row
left them blank; `pull_lb` is `None`, not '',
when the row did not override it)
'openings_rows' -- [{'type', 'pos_along', 'pos_along_from', 'length',
'pos_cross', 'pos_cross_from', 'width',
'pos_vert', 'height', 'label'}, ...] -- the
rows the Concrete Extents page and the sketches
draw, either parsed from the `openings` text or
carried straight through from the model read
(`reads['openings_rows']`, which the button hands
`prefill` as a list via PASSTHROUGH_KEYS)
'openings' -- the raw text of that field, unparsed, still here
'stage_matrix' -- {stage: {opt_key: value}}, see
`_parse_stage_matrix`
'restraints' -- [{'x_ft', 'type', 'elevation_ft', 'gravity',
'thermal', 'stiffness_kin'}, ...] or None, see
`_restraints_from` -- the `restraint` table's
ELEVATIONS converted to positions along the panel
'conc_loads_rows' -- [{'elevation_ft', 'x_ft', 'offset_in', 'dead_lb',
'live_lb', 'label'}, ...], see
`_conc_loads_from`
'wind' -- {'mode': 'manual', 'zones': [...]} or None, see
`_wind_from` -- the `wind_zone` table's manually
typed pressure/suction zones
'mild_bars' -- [{'size', 'count', 'area_in2', 'depth_in',
'direction', 'from_ft', 'to_ft'}, ...], see
`_mild_bars_from` -- the `mild_bar` table,
NEVER blocking (a bad row is skipped, not
refused)
'mild_bars_note' -- '; '-joined notes from `_mild_bars_from` (a
skipped row, a blank depth), or None
'bottom_elevation_note' -- the note `_bottom_elevation` returned, or
None -- carried so `build_deck` can say, once, that
a 0 ft bottom was assumed rather than read
'service' -- the `loading.service` block dict, or None, see
`_service_from`
`openings_rows` / `strand_rows` are handed to `panelsketch` verbatim by
the button -- the exact dict keys above are that module's contract, and
changing them without changing that module's reader breaks its drawing.
Things that reach the in-place solve for real now BLOCK the Run button
on bad data rather than sitting as a harmless unread note: a
concentrated-load row that will not parse (`_conc_loads_from`), a
ticked load-combination row carrying an unreadable factor
(`load_combinations`'s own `problem`), and -- 2026-09-04 -- a
`wind_zone` row with a pressure/suction typed but no valid elevation/
width (`_wind_from`), and `wind_mode` set to 'From project config',
which this form does not build yet and refuses to run rather than
fabricate a pressure for.
"""
values = values or {}
errors = []
parsed = {'mark': (values.get('mark') or '').strip() or None,
'design_number': (values.get('design_number') or '').strip()
or None}
skip = set(_PRESTRESS_KEYS) | set(_PRESTRESS_EXTRA_KEYS)
for f in FIELDS:
key, label, req, kind, drives = (
f['key'], f['label'], f['required'], f['kind'], f['drives'])
if key in skip:
continue
if kind == 'num':
val, err = _parse_num(values.get(key))
elif kind == 'int':
val, err = _parse_int(values.get(key))
else:
continue
blank = val is None and not err
if req and blank:
errors.append('{0} is required'.format(display_label(key)))
elif err and (req or drives == 'solve'):
errors.append('{0} {1}'.format(display_label(key), err))
parsed[key] = val
# Support positions: the `support` ROW TABLE wins whenever it carries any
# present row (owner ruling, 2026-09-05 -- see `SUPPORT_TABLE`); the old
# `supports_ft` text field is read only as a fallback, for a panel saved
# before this table existed and carrying no `support.*` rows at all.
support_rows = rows_of(SUPPORT_TABLE, values, SUPPORT_COLS)
if support_rows:
xs = []
err = None
for row in support_rows:
v, e = _parse_num(row.get('x_ft'))
if e or v is None:
err = 'Support row {0}: x {1}'.format(
row['n'], e or 'is required')
break
xs.append(v)
else:
xs, err = parse_supports(values.get('supports_ft'))
if err:
errors.append(err)
parsed['supports'] = None
elif len(xs) < 2:
errors.append('{0}: at least two support positions are needed (a '
'beam needs >= 2 supports); got {1}'.format(
display_label('supports_ft'), len(xs)))
parsed['supports'] = xs
else:
parsed['supports'] = xs
# Structured values ride through into `parsed` UNCHANGED, under their own
# keys (`values['openings_rows']` -> `parsed['openings_rows']`, the same
# list of row dicts the Concrete Extents page and the sketches read).
# They are copied verbatim -- never reparsed as text -- and absent is the
# real "no openings" answer, `[]`, never a guessed shape. These keys are
# NOT `FIELDS` entries, so the string-parsing loop above never sees them.
for key in PASSTHROUGH_KEYS:
parsed[key] = list(values.get(key) or [])
# Openings/reveals/solid zones: the `opening` ROW TABLE wins whenever it
# carries any present row (owner ruling, 2026-09-05 -- see
# `OPENING_TABLE`), converted to the same
# `{type, pos_along, pos_along_from, length, pos_cross, pos_cross_from,
# width, pos_vert, height, label}` shape `_openings_for_deck` /
# `panelsketch` already read, so neither needed to change for the table
# to replace the old free-text/passthrough source. The legacy free-text
# `openings` field and the raw `openings_rows` passthrough (already
# copied above) are read only when the table has no rows at all -- a
# panel saved before this table existed.
table_opening_rows = _opening_rows_from_table(values)
if table_opening_rows:
parsed['openings_rows'] = table_opening_rows
else:
opening_rows, opening_problems = parse_openings(values.get('openings'))
if opening_problems:
for p in opening_problems:
errors.append('{0}: {1}'.format(display_label('openings'), p))
parsed['openings_rows'] = None
elif opening_rows:
# Typed free-text rows win over the structured list when the text
# actually parsed rows -- the text is the caller's own words, and
# a caller who typed them means them. An EMPTY text parse (blank
# `openings`) never overwrites the structured rows the
# PASSTHROUGH_KEYS copy above already carried.
parsed['openings_rows'] = opening_rows
parsed['openings'] = values.get('openings') or ''
prestress, strand_rows = _prestress_from(values, parsed.get('width_ft'),
errors)
parsed['prestress'] = prestress
parsed['strand_rows'] = strand_rows
parsed['mild_bars'], mild_notes = _mild_bars_from(
values, parsed.get('length_ft'))
parsed['mild_bars_note'] = '; '.join(mild_notes) if mild_notes else None
parsed['stage_matrix'] = _parse_stage_matrix(values, errors)
# This panel's own bottom elevation -- shared by the restraints, wind
# and concentrated-load conversions below, so all three agree on the
# same assumption (and the same note) when the form does not carry one.
bottom_ft, bottom_note = _bottom_elevation(values)
parsed['bottom_elevation_note'] = bottom_note
# Wind: 'From project config' is a form-level refusal, not a deck error
# -- this form does not compute ASCE pressures, so running it anyway
# would be fabricating one. Manual wind zones are collected into the
# deck by `_wind_from`; a filled-in zone that will not parse IS a hard
# error (see that function), but zero zones is a real "no wind case"
# answer, never an error.
wind_mode_text = (values.get('wind_mode') or 'Manual').strip()
if wind_mode_text.lower() == 'from project config':
errors.append(
"Wind input mode is set to 'From project config', which this "
'form does not build yet -- switch it to Manual and type the '
'pressure/suction directly, or leave it blank')
parsed['wind'] = _wind_from(values, parsed.get('length_ft'), bottom_ft,
errors)
# In-place restraints, typed as ELEVATIONS and converted to positions
# along the panel -- optional (blank leaves the deck's unread
# placeholder), but a converted position outside [0, length_ft] IS a
# hard error (see `_restraints_from`).
parsed['restraints'] = _restraints_from(values, parsed.get('length_ft'),
bottom_ft, errors)
# Concentrated (bearing) loads, converted the same elevation -> position
# way. Unlike wind/restraints, a PRESENT row that will not parse IS a
# hard error: this now reaches `loading.applied_axial` and feeds the
# in-place solve, so garbage silently carried through would understate
# or mislocate a real load rather than merely sit unread on the page.
cl_rows = _conc_loads_from(values, bottom_ft, errors)
parsed['conc_loads_rows'] = cl_rows if cl_rows is not None else []
# The Stage-3 SERVICE inputs (deflection limit, initial bow, axial
# eccentricity, cracking assumption) -- optional, never blocks the run;
# a source field that will not parse is dropped to its could-not-judge
# default with the reason folded into the block's own `_note` instead.
parsed['service'] = _service_from(values)
# The ticked load combinations. Since 2026-09-04 this reaches the deck as
# a real list `panelanalysis_schema._parse_combinations` reads (see
# `_combinations_for_deck` / `build_deck`), so a row carrying an
# unreadable factor (`problem`) IS now a hard error -- it is no longer a
# note nobody acts on, it is a factor the in-place solve would otherwise
# silently treat as excluded (0) rather than "could not be read".
combo_rows = load_combinations(values)
for row in combo_rows:
if row.get('problem'):
errors.append(row['problem'])
parsed['load_combinations'] = combo_rows
if errors:
return errors, None
return [], parsed
def is_complete(values):
"""True when the panel's required fields are all filled and valid."""
errors, _parsed = validate(values)
return not errors
# --------------------------------------------------------------- deck build
def _unread(reason, value=None):
"""The gathered-but-unread shape the schema uses: null + why."""
return {'value': value, 'reason': str(reason)}
def _or(value, default):
return default if value is None else value
def build_deck(parsed):
"""A `panelanalysis/1` deck from validated form values.
Mirrors `panelanalysis_schema.new_deck`'s shape -- every unbuilt block is
an explicit `_unread` null + reason, never a silent omission or a
fabricated zero. Only the `drives == 'solve'` fields drive the HANDLING
(Stage 0) solve; the Eriksson stage3 inputs are collected on the form so
it is Eriksson-complete, and -- since 2026-09-04 -- five of them now also
reach the IN-PLACE (Stage 3) engine's own input contract, wired here:
* `loading.service` deflection limit, initial bow, axial
eccentricity, cracking assumption
(`_service_from`)
* `loading.applied_axial` the `conc_load` table's concentrated bearing
loads, converted from elevations
(`_conc_loads_from`)
* `loading.wind` {'mode': 'manual', 'zones': [...]} -- the
`wind_zone` table's manually typed
pressure/suction zones, converted from
elevations (`_wind_from`). 2026-09-04: the
ASCE-computed path is GONE, moved to
project config; see the module docstring's
Wind section.
* `loading.combinations` the ticked load combinations, as the LIST
`panelanalysis_schema._parse_combinations`
reads (`_combinations_for_deck`) -- a dict
of `rows` used to be written here instead,
which `_parse_combinations` never reads
(`isinstance(combo_blk, list)` guards it),
so every combination silently fell back to
the engine's own ASCE default set
* `structural_model.restraints` the `restraint` table's ELEVATIONS,
converted to positions along the panel
(`_restraints_from`)
* `concrete_extents.blockouts` / `.recesses` the `openings_rows`,
translated (`_openings_for_deck`)
* `reinforcement.mild_bars` the `mild_bar` table, additive alongside
`reinforcement.prestress` -- 2026-09-04,
see `_mild_bars_from`. NEVER blocks the Run
button; a row this cannot read is skipped
and named in `_mild_bars_note` instead.
Whether the in-place engine actually HONOURS a deck-supplied combination
list for both strength and service was checked against `inplace.py`
directly, not assumed: `run()` reads `inputs.combinations or
ASCE_7_16_STRENGTH` and filters `kind == 'strength'`
(panellift/src/panellift/inplace.py:1134); `run_service()` reads the
SAME `inputs.combinations`, filtered `kind == 'service'`, falling back to
`ASCE_7_16_SERVICE` only when nothing of that kind was supplied
(panellift/src/panellift/inplace.py:1097-1098). So one list carrying both
kinds -- exactly what `_combinations_for_deck` emits -- drives both
sides; there is no second wiring needed for service.
Handling (Stage 0) is untouched: it does not read `structural_model.
restraints`, `loading.applied_axial`, `loading.combinations` or
`loading.service` at all (`panelanalysis_schema.handling_stage` /
`.handling_stages` only ever read `structural_model.supports` and
`loading.self_weight`), so none of this changes a single Handling number.
`parsed` is the second element `validate` returns; calling this on the
None a failed validation returns is a programming error, so it is not
guarded (the button only reaches it past the gate).
"""
prestress = parsed.get('prestress')
if prestress:
reinforcement = {'prestress': prestress}
loss_pct = prestress.get('loss_pct')
else:
reinforcement = {
'prestress': None,
'_note': ('no prestress entered on the form; capacity is '
'strand-only, so flexural capacity is not computed and '
'the page says so'),
}
loss_pct = None
# Additive, alongside `prestress` either way -- a rebar (non-prestressed)
# wall panel and a prestressed one both carry this key, `[]` when the
# table is empty (see `_mild_bars_from`; never blocks the Run button).
reinforcement['mild_bars'] = parsed.get('mild_bars') or []
if parsed.get('mild_bars_note'):
reinforcement['_mild_bars_note'] = parsed['mild_bars_note']
handling = {
'_note': ('result block; filled by panelanalysis.py from the '
'self-weight solve on structural_model.supports'),
}
blockouts, recesses, opening_notes = _openings_for_deck(
parsed.get('openings_rows'), parsed.get('length_ft'),
parsed.get('width_ft'))
concrete_extents = {
'length_ft': parsed.get('length_ft'),
'width_ft': parsed.get('width_ft'),
'thickness_in': parsed.get('thickness_in'),
'finish': 'smooth',
'product_type': 'flat',
'weight_lb': parsed.get('weight_lb'),
'blockouts': blockouts,
'recesses': recesses,
}
if opening_notes:
concrete_extents['_openings_note'] = '; '.join(opening_notes)
bottom_note = parsed.get('bottom_elevation_note')
structural_model = {
'supports': [{'x_ft': float(x), 'type': 'support'}
for x in parsed['supports']],
'restraints': (parsed.get('restraints') if parsed.get('restraints')
else _unread(
'in-place restraints (foundation + floor ties) were not entered '
'on the form; the in-place model spans these, so it stays a '
'placeholder until they are given')),
'bearing': _unread(
'in-place bearing is not collected on this form'),
}
if parsed.get('restraints') and bottom_note:
structural_model['_restraints_bottom_note'] = bottom_note
applied_axial = [
{'x_ft': r['x_ft'], 'dead_lb': r['dead_lb'], 'live_lb': r['live_lb'],
'label': r['label'], 'offset_in': r['offset_in'],
'elevation_ft': r['elevation_ft']}
for r in (parsed.get('conc_loads_rows') or [])
]
service = parsed.get('service')
loading = {
'self_weight': {
'included': True,
'load_factor': _or(parsed.get('self_weight_factor'),
DEFAULT_SELF_WEIGHT_FACTOR),
},
'wind': (parsed.get('wind') if parsed.get('wind')
else _unread('no wind pressure/suction entered on the '
'form; the in-place wind case is absent -- '
'type a pressure or suction into a wind zone '
'to add one')),
# A LIST, the shape `panelanalysis_schema._parse_combinations`
# actually reads (`isinstance(combo_blk, list)`) -- see this
# function's own docstring for why the dict this used to be written
# as never reached the solve.
'combinations': _combinations_for_deck(parsed.get('load_combinations')),
'service': (service if service is not None else _unread(
'in-place SERVICE inputs (L/n deflection limit, Class T '
'cracking, initial bow, axial eccentricity) were not entered on '
'the form; the ASCE 2.4 default combinations still run without '
'them, just with every could-not-judge default (no deflection '
'limit, Class U, no bow, no eccentricity)')),
}
if applied_axial:
loading['applied_axial'] = applied_axial
if bottom_note:
loading['_applied_axial_bottom_note'] = bottom_note
stage_matrix = parsed.get('stage_matrix') or {}
for stage, deck_key in _STAGE_DECK_MULT_KEY.items():
row = stage_matrix.get(stage) or {}
if row.get('include') is False:
# Excluded stage: its multiplier does not drive the deck, but it
# was never dropped from `parsed['stage_matrix']` above -- see
# `_parse_stage_matrix`.
continue
v = row.get('load_multiplier')
if isinstance(v, (int, float)):
handling[deck_key] = v
return {
'schema': SCHEMA,
'project_information': {
'mark': parsed.get('mark') or 'PANEL',
'design_number': parsed.get('design_number'),
'fc_psi': parsed.get('fc_psi'),
'fci_psi': parsed.get('fci_psi'),
'unit_weight_pcf': parsed.get('unit_weight_pcf'),
'prestress_loss_pct': loss_pct,
# Reference-only fields the form already collects (`FIELDS`,
# 'Design Criteria' / 'Project Info') but this dict never
# carried through to the deck -- the results page's title line
# wants them (2026-09 results-page redesign). No 'location'
# equivalent exists on this form at all today; not invented
# here.
'project_name': parsed.get('project_name'),
'client_name': parsed.get('client_name'),
'job_number': parsed.get('job_number'),
},
'settings': {
'tension_limit_sqrt_fc': _or(parsed.get('handling_tension_sqrt'),
DEFAULT_TENSION_LIMIT_SQRT_FC),
'compression_limit_ratio': _or(parsed.get('handling_comp_ratio'),
DEFAULT_COMPRESSION_LIMIT_RATIO),
'phi_flexure': _or(parsed.get('phi_flexure'), DEFAULT_PHI_FLEXURE),
'phi_shear': _or(parsed.get('phi_shear'), DEFAULT_PHI_SHEAR),
'development_length_multiplier': _or(parsed.get('dev_mult'),
DEFAULT_DEV_MULT),
'_seeded_from': _unread(
'stress limits / resistance factors default to the panellift '
'values unless the user set them on the form; the full '
'knowledge-seeded settings page is a later stage'),
},
'concrete_extents': concrete_extents,
'structural_model': structural_model,
'reinforcement': reinforcement,
'loading': loading,
'handling': handling,
'in_place': _unread('result block written by the engine, never by '
'the form -- panelanalysis.py fills it from the '
'in-place wind/gravity solve on structural_model.'
'restraints when there are at least two'),
'source': {
'synthetic': False,
'entered_by': 'panel-analysis input form',
'note': ('geometry / materials pre-filled from the model where '
'readable, confirmed and completed by the user on the '
'Panel Analysis input form'),
},
}
# ------------------------------------------------------------------ batch
def batch_plan(named_values):
"""(to_run, skipped) for an Analyze-All over several panels.
`named_values` is [(label, values), ...] -- one per selected panel.
`to_run` is [(label, parsed)] for the complete ones; `skipped` is
[(label, errors)] for the rest, each carrying WHY it was skipped. Nothing
is silently run incomplete and nothing is silently dropped -- the owner's
rule for Analyze All -- so the caller reports both lists.
"""
to_run = []
skipped = []
for label, values in named_values:
errors, parsed = validate(values)
if errors:
skipped.append((label, errors))
else:
to_run.append((label, parsed))
return to_run, skipped