25592fa6c2
ci / test (push) Successful in 6m14s
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.
3995 lines
190 KiB
Python
3995 lines
190 KiB
Python
# -*- 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
|