Files
willtools-demo/WillTools.extension/lib/vibe/panelanalysisform.py
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
Public demo snapshot of an in-house pyRevit extension for precast concrete
shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel
handling solver (PCI Chapter 8).

- No client, job, plant or product data: knowledge layers ship as
  structural templates only.
- Ticket QC's model-read escalation targets a LAN model server endpoint
  (configurable), not a cloud provider.
- Built from the private development repo via tools/make_dist.py.
2026-09-03 16:33:47 -05:00

1593 lines
73 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Panel Analysis input form -- pure logic, both Pythons, no Revit.
The Panel Analysis button pops a confirmation form: one collapsible section
per selected structural-framing panel, each pre-filled with what the model
could read and BLANK where it could not, and each gated so the analysis cannot
run until the fields it needs are filled (the owner's rule, 2026-09-02:
"按完以后出一个输入界面,在这个界面里面预填充能读到的数据,读不到就留空白,
用户不输入就不让用户跑分析。"
Press, then a form; pre-fill what can be read, blank what cannot; do not
let the user run the analysis until they have filled it in.)
THE FIELDS REPLICATE ERIKSSON WALL'S OWN INPUT SECTIONS, verbatim-named
(owner, 2026-09-02: "表单照着eriksson wall抄 ... Design Criteria, Concrete
Extents, Structural Model, Handling, Reinforcement 这几个板块,都给我照着复刻").
The field list is taken from Eriksson's actual per-section inputs
(`docs/eriksson-wall-method.md` §A.1 and the extracted golden samples), NOT
invented -- but every LABEL and value here is generic precast nomenclature; no
client, job or sample value is carried. The five section names are the whole
of what "照着复刻" fixed; the GROUPS inside each section are new, added to
mirror Eriksson's own page-by-page blocks (Material Properties / Prestress
Losses / Load Combinations inside Design Criteria; a Stage Options matrix
inside Handling; and so on) without inventing a sixth top-level section.
THE SECOND OWNER RULING THIS MODULE ANSWERS, 2026-09-02, is legibility, not
completeness: "全都做掉。全部给我做成普通人能看懂的样子" after twice calling the
form unreadable ("太丑了", "你这么输入用户上哪儿能看懂"). A field used to carry
ONLY its engineering name -- `Handling tension limit (n·√f′ci)` -- as if the
symbol were the explanation. Every field below instead carries a plain-English
`label` a non-engineer can read, an engineering `symbol` beside it (never
instead of it -- the Unicode math is not decoration, it is what a reviewing
engineer checks against Eriksson's own printout), and a one-sentence `help`
string saying in words what the field is and what happens if it is left
blank. `display_label(key)` is what an error message names a field by --
label AND symbol together, so a person who thinks in either vocabulary can
find the field the message means.
Each field declares which Eriksson SECTION (and, inside it, which GROUP) it
belongs to, and whether it `drives` the Stage-0 self-weight solve:
'solve' -- consumed by the `panelanalysis/1` deck the Stage-0 tool reads.
'stage3' -- a real Eriksson input the Stage-0 solve does NOT use yet
(in-place wind / restraints / mild steel / prestress-loss
method / stage rigging / ...). Shown so the form is
Eriksson-complete, marked as not-yet-driving-analysis, never
faked into a result and never dropped.
'info' -- a reference/computed value (section type, etc.).
This module is the SEAM between the WPF form (owner-must-verify, in Revit) and
the deck. It imports no Revit and no `pyrevit`, so it loads on plain CPython
the way the rest of the Revit-free `vibe` does (see `tests/conftest.py`), and
it must run under IronPython 2.7 in the button: no f-strings, no typing, no
dataclasses.
WHY THE DECK IS BUILT BY HAND HERE, not through `panelanalysis_schema`: that
schema imports `panellift` (CPython dataclasses) and cannot be imported from
IronPython -- the same boundary `liftexport.py` builds the `panellift/1` job
dict by hand across. `build_deck`'s shape mirrors `panelanalysis_schema.
new_deck`, and the host tests feed it straight back through `schema.to_panel` +
the solve to prove the two agree.
WHY OPENINGS DO NOT REACH `blockouts` YET: `panelanalysis_schema.to_panel`
feeds `concrete_extents.blockouts` straight into `panellift.cli.read_panel`,
which expects `{name, x0, y0, length, width, depth_in}` -- a DIFFERENT shape
than the `{x_ft, y_ft, w_ft, h_ft, label}` rows this form now collects (chosen
to match `parse_strand_rows`'s plain-English style and the sibling
`panelsketch` module's drawing contract). Translating one shape into the other
is real work -- units, which corner, whether a panel-local frame matches the
solve's -- and doing it silently here would be exactly the kind of invented
default this codebase refuses. So `openings` is marked `drives='stage3'`:
collected, parsed, validated, shown on the form and to `panelsketch`, but kept
OUT of `build_deck` until a promotion pass does that translation on purpose.
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.
_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,
'0.5625': 0.192, '9/16': 0.192,
'0.6': 0.217, '3/5': 0.217,
'0.7': 0.294,
}
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
# Internal-pressure coefficient GCpi magnitude by enclosure classification,
# ASCE 7 Table 26.13-1 (same value in 7-16 and 7-22). This MIRRORS
# `panellift.windload._GCPI` -- the owner's requirement is that the form's
# enclosure choice shows its matching GCpi, and the form runs under IronPython
# 2.7 where `windload` (CPython dataclasses/typing) cannot be imported. The
# value is a published code constant, not a plant standard; a host test
# cross-checks this table against `windload._GCPI` so the two can never drift.
WIND_GCPI = {
'enclosed': 0.18,
'partially_enclosed': 0.55,
'partially_open': 0.18,
'open': 0.00,
}
WIND_GCPI_CITE = 'ASCE 7 Table 26.13-1 (internal pressure coefficient GCpi)'
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 / 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'),
_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('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_days', 'Days from casting to release', '',
'How many days 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. Not yet used by the solve.',
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. Not yet used by the solve.',
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', '',
'A short description of the cross-section (solid, sandwich, '
'ribbed, ...). Reference only.',
False, 'text', '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'),
_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'),
_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)
# =============================================================
_F('supports_ft', 'Support positions', 'ft, along the length',
'Where the panel is held up for THIS analysis, measured along its '
'length; comma-separated, at least two. Required -- an unsupported '
'panel cannot be checked.',
True, 'supports', 'Structural Model', None, 'solve'),
_F('restraints', 'In-place restraint elevations', 'ft',
'Where the panel is tied back or braced once it is standing '
'(foundation, floor ties, ...). Not yet used by the solve.',
False, 'text', 'Structural Model', 'Restraints & Bearing', 'stage3'),
_F('conc_loads', 'Point loads', 'elevation, offset, dead/live',
'Any extra concentrated load hung on the panel once it is in '
'place, and where. Not yet used by the solve.',
False, 'text', 'Structural Model', 'Restraints & Bearing', 'stage3'),
# Wind: ASCE inputs, all blank for the user to type. The ASCE pressure
# engine is a later stage; a wind engine will PRE-FILL these the same way
# the model pre-fills geometry, so the seam is left clean here. Nothing
# computes wind and no pressure is faked -- these are collected-but-not-yet
# -driving (owner, 2026-09-02: "风的计算还没有你就留着空着,让用户输入").
_F('wind_speed_vult', 'Design wind speed', 'Vult, mph',
'The ultimate design wind speed from ASCE 7 for this site. Not yet '
'used by the solve -- nothing computes wind pressure yet.',
False, 'num', 'Structural Model', 'Wind', 'stage3'),
_F('wind_risk_category', 'Risk category', '',
'The ASCE 7 risk category for the building this panel is part of. '
'Not yet used by the solve.',
False, 'text', 'Structural Model', 'Wind', 'stage3'),
_F('wind_exposure', 'Exposure category', '',
'The ASCE 7 wind exposure category for the site (B, C, or D). Not '
'yet used by the solve.',
False, 'text', 'Structural Model', 'Wind', 'stage3'),
_F('wind_enclosure', 'Enclosure classification', '',
'Whether the building is enclosed, partially enclosed, or open, '
'per ASCE 7. Not yet used by the solve.',
False, 'text', 'Structural Model', 'Wind', 'stage3'),
_F('wind_asce_version', 'ASCE code version', '',
'Which edition of ASCE 7 the wind numbers below are taken from. '
'Not yet used by the solve.',
False, 'text', 'Structural Model', 'Wind', 'stage3'),
_F('wind_cc_chapter', 'Components & Cladding chapter used', '',
'Which ASCE 7 chapter the panel is treated under as a piece of '
'cladding. Not yet used by the solve.',
False, 'text', 'Structural Model', 'Wind', 'stage3'),
_F('wind_mwfrs_chapter', 'Main wind force resisting system chapter '
'used', '',
"Which ASCE 7 chapter the panel is treated under as part of the "
"building's main wind system. Not yet used by the solve.",
False, 'text', 'Structural Model', 'Wind', 'stage3'),
_F('wind_mean_roof_height_ft', 'Mean roof height', 'h, ft',
'The mean roof height h of the building this panel is part of, from '
'ASCE 7 -- one of the three numbers the in-place C&C wind pressure is '
'computed from. Leave blank to skip the computed pressure.',
False, 'num', 'Structural Model', 'Wind', 'stage3'),
_F('wind_effective_wind_area_ft2', 'Effective wind area', u'ft²',
'The effective wind area used to look up the C&C pressure coefficient, '
'per ASCE 7 -- one of the three numbers the in-place wind pressure is '
'computed from. Leave blank to skip the computed pressure.',
False, 'num', 'Structural Model', 'Wind', 'stage3'),
_F('wind_pressure_psf', 'Wind pressure (typed directly)', 'psf',
'A wind pressure you already know and want used as-is, instead of '
'it being computed. Not yet used by the solve.',
False, 'num', 'Structural Model', 'Wind', 'stage3'),
_F('wind_suction_psf', 'Wind suction (typed directly)', 'psf',
'A wind suction (negative pressure) you already know and want '
'used as-is. Not yet used by the solve.',
False, 'num', '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)
# =============================================================
_F('ps_count', 'Strand count (cross-check)', '',
'The total number of prestressing strands, if you want it checked '
'against the strand groups below. Leave blank to skip the check -- '
'the groups below are what actually gets used.',
False, 'int', 'Reinforcement', 'Prestressing Strands', 'solve'),
_F('ps_dia_in', 'Strand diameter', 'in',
'The prestressing strand size (e.g. 1/2, 3/8), used to look up its '
'cross-sectional area. Required once any prestress field is filled.',
False, 'text', 'Reinforcement', 'Prestressing Strands', 'solve'),
_F('ps_pull_lb', 'Initial pull per strand (default)', 'lb',
'How hard each strand is jacked, in pounds -- used for any strand '
'group below that does not give its own pull percentage. Required '
'once any prestress field is filled.',
False, 'num', 'Reinforcement', 'Prestressing Strands', 'solve'),
_F('ps_rows', 'Strand groups', '',
'One strand group per line: how many strands, how deep from the '
"bottom, and (optionally) what percentage of the strand's ultimate "
"strength they are pulled to -- 'count @ depth_in [@ pull_pct]', "
"e.g. '4 @ 2 @ 75'. Required once any prestress field is filled.",
False, 'strands', 'Reinforcement', 'Prestressing Strands', 'solve'),
_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. Not yet used by '
'the solve.',
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'),
_F('mild_as', 'Mild reinforcement', u'Aₛ (in²) / bars',
'The ordinary (non-prestressed) reinforcing bar in the panel -- '
'area and/or bar callouts. Not yet used by the solve.',
False, 'text', 'Reinforcement', 'Mild Reinforcement', 'stage3'),
_F('mesh', 'Welded wire mesh', 'WWF',
'The welded wire mesh / fabric callout for the panel, if any. Not '
'yet used by the solve.',
False, 'text', 'Reinforcement', 'Mild Reinforcement', 'stage3'),
]
# The prestress fields form one optional group: all blank means "no
# prestress", any filled means "describe it fully". `ps_ecc_in` is gone --
# replaced by `ps_rows`, one strand GROUP per line (count/depth/pull%), the
# shape a real strand schedule is actually written in (see the module
# docstring's ps_rows entry and `parse_strand_rows`).
_PRESTRESS_KEYS = ('ps_count', 'ps_dia_in', 'ps_pull_lb', 'ps_rows')
# 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')
# 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')
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):
"""Initial form values (key -> string) from what the model supplied.
`reads` carries the `PREFILLABLE` keys the button read off the framing
element; 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, openings, the stage matrix, all the stage3 in-place
inputs) starts blank too.
"""
reads = reads or {}
values = {}
for f in FIELDS:
key = f['key']
values[key] = _fmt(reads.get(key)) if key in PREFILLABLE else ''
for key in _HEADER_KEYS:
values[key] = _fmt(reads.get(key))
for key in stage_field_keys():
values[key] = ''
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, []
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 = str(dia_text).strip().lower().replace('"', '').replace(' ', '')
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
#
# The wind C&C pressure is COMPUTED by `panellift.windload` -- a CPython module
# (dataclasses + typing) that this IronPython 2.7 form cannot import. So the
# form only COLLECTS the ASCE inputs + any direct-entry pressure and carries
# them into the deck; the CPython deck-solve (`tools/panelanalysis.py` via
# `panelanalysis_schema`) runs `windload.compute`, applies the precedence
# below, and feeds the result into the in-place wind case. The two things the
# form itself decides -- the enclosure -> GCpi readout, and which pressure
# wins -- are pure and host-tested here; nothing is computed or faked in Revit.
def _normalize_enclosure(enclosure):
"""Canonical enclosure key or None. Mirrors windload._normalize_enclosure."""
if enclosure is None:
return None
key = str(enclosure).strip().lower().replace('-', '_').replace(' ', '_')
aliases = {
'enclosed': 'enclosed',
'partially_enclosed': 'partially_enclosed',
'partial_enclosed': 'partially_enclosed',
'partially_open': 'partially_open',
'partial_open': 'partially_open',
'open': 'open',
}
return aliases.get(key)
def wind_gcpi(enclosure):
"""(gcpi_magnitude, cite) for an enclosure, or (None, reason).
The owner's explicit requirement: choosing an enclosure classification shows
its matching GCpi. Blank or an unrecognised word returns None + a reason
(the could-not-judge discipline -- never a fabricated 0.0, which is the real
GCpi of an OPEN building and would read as "judged, found zero").
"""
if enclosure is None or not str(enclosure).strip():
return None, 'no enclosure classification chosen yet'
key = _normalize_enclosure(enclosure)
if key is None:
return None, ('enclosure {0!r} not recognized; Table 26.13-1 defines '
'enclosed / partially_enclosed / partially_open / open'
.format(enclosure))
return WIND_GCPI[key], WIND_GCPI_CITE
def resolve_wind_pressure(direct_psf, computed_psf, computed_reason=None):
"""Which out-of-plane wind pressure the in-place model uses, and why.
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):
"""The `loading.wind` deck block from the raw form values, or None.
None when the user supplied NONE of the wind inputs -- the block then stays
the explicit unread placeholder `build_deck` writes. Otherwise a dict
carrying the ASCE inputs the CPython solve feeds `windload.compute`, plus
any direct-entry pressure/suction. The `asce` sub-block is emitted only when
the three numeric inputs `windload.compute` cannot run without -- Vult, mean
roof height, effective wind area -- are all present; a partial ASCE entry
without them yields no `asce` (and the page reports what is still needed)
rather than a value guessed past a blank.
"""
values = values or {}
def _txt(key):
v = (values.get(key) or '').strip()
return v or None
def _n(key):
v, _err = _parse_num(values.get(key))
return v
vult = _n('wind_speed_vult')
h = _n('wind_mean_roof_height_ft')
area = _n('wind_effective_wind_area_ft2')
direct_p = _n('wind_pressure_psf')
direct_s = _n('wind_suction_psf')
risk = _txt('wind_risk_category')
exposure = _txt('wind_exposure')
enclosure = _txt('wind_enclosure')
version = _txt('wind_asce_version')
cc = _txt('wind_cc_chapter')
supplied = [x for x in (vult, h, area, direct_p, direct_s, risk, exposure,
enclosure, version, cc) if x is not None]
if not supplied:
return None
block = {}
if vult is not None and h is not None and area is not None:
block['asce'] = {
'v_ult_mph': vult,
'risk_category': risk,
'exposure': exposure,
'enclosure': enclosure,
'code_version': version,
'mean_roof_height_ft': h,
'effective_wind_area_ft2': area,
'cc_chapter': cc,
}
else:
# Something wind-related was typed, but not enough to compute. Record
# what is missing rather than silently dropping it.
missing = []
if vult is None:
missing.append('Vult (mph)')
if h is None:
missing.append('mean roof height (ft)')
if area is None:
missing.append('effective wind area (ft^2)')
block['asce_incomplete'] = (
'ASCE C&C pressure not computed: still need ' + ', '.join(missing))
if direct_p is not None:
block['direct_pressure_psf'] = direct_p
if direct_s is not None:
block['direct_suction_psf'] = direct_s
return block
def _restraints_from(values):
"""[{x_ft, type}] for the in-place lateral restraints, or None.
Parsed from the free-text `restraints` field the same way supports are.
None (blank) leaves the deck's restraints block the explicit unread
placeholder. Fewer than two is NOT refused here -- the in-place solve makes
that refusal in its own words, the same as the handling supports gate.
"""
xs, err = parse_supports((values or {}).get('restraints'))
if err or not xs:
return None
return [{'x_ft': float(x), 'type': 'restraint'} for x in xs]
# ------------------------------------------------------------ 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',)),
('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 _prestress_from(values, width_ft, errors):
"""(prestress_block_or_None, strand_rows).
All prestress fields blank -> (None, []) -- a real "no prestress" answer.
Any filled -> diameter / default pull / strand groups must all be
present and valid, or it is an error named field by field; `ps_count`,
if given, is checked against the strand groups' total and a MISMATCH is
reported rather than either side silently winning (the owner's rule for
a contradiction: surface it, never retire the question by picking a
side). Strands within each group are spread evenly across the panel
WIDTH at that group's own depth -- still a Stage-1 idealised layout from
what the user typed, honest about being an input rather than a
per-strand model read, same as before `ps_rows` replaced `ps_ecc_in`.
"""
filled = [k for k in _PRESTRESS_KEYS if (values.get(k) or '').strip()]
if not filled:
return None, []
count, err = _parse_int(values.get('ps_count'))
if err:
errors.append('{0} {1}'.format(display_label('ps_count'), err))
count = None
if (values.get('ps_dia_in') or '').strip() == '':
errors.append('{0} is required once any prestress field is filled'
.format(display_label('ps_dia_in')))
area = None
else:
area, err = strand_area_in2(values.get('ps_dia_in'))
if err:
errors.append(err)
pull, err = _parse_num(values.get('ps_pull_lb'))
if err:
errors.append('{0} {1}'.format(display_label('ps_pull_lb'), err))
pull = None
elif pull is None:
errors.append('{0} is required once any prestress field is filled'
.format(display_label('ps_pull_lb')))
elif pull <= 0:
errors.append('{0} must be positive (the jacking force per strand)'
.format(display_label('ps_pull_lb')))
dia, _err = _parse_num(values.get('ps_dia_in'))
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
rows, problems = parse_strand_rows(values.get('ps_rows'))
if problems:
for p in problems:
errors.append('{0}: {1}'.format(display_label('ps_rows'), p))
return None, []
if not rows:
errors.append('{0} is required once any prestress field is filled'
.format(display_label('ps_rows')))
return None, []
total_count = sum(r['count'] for r in rows)
if count is not None and count != total_count:
errors.append(
'{0} ({1}) does not match the sum of the strand groups below '
'({2}) -- fix whichever one is wrong rather than guessing which '
'to trust'.format(display_label('ps_count'), count, total_count))
if area is None or pull is None or not width_ft:
return None, rows
fpu_val = fpu if fpu is not None else DEFAULT_FPU_KSI
strands = []
idx = 0
for row in rows:
row_pull = pull
if row.get('pull_pct') is not None:
row_pull = (row['pull_pct'] / 100.0) * fpu_val * 1000.0 * area
for _j in range(row['count']):
idx += 1
y = (idx - 0.5) / float(total_count) * width_ft
strands.append({
'y': round(y, 4), 'z_in': row['depth_in'], 'area_in2': area,
'initial_pull_lb': row_pull, 'label': 'S{0}'.format(idx)})
prestress = {
'diameter_in': dia if dia is not None else 0.5,
'loss_pct': loss if loss is not None else DEFAULT_LOSS_PCT,
'fpu_ksi': fpu_val,
'strands': strands,
'rows': rows,
}
return prestress, rows
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' -- [{'count', 'depth_in', 'pull_pct'}, ...] the user
typed into `ps_rows`, whether or not a full
prestress block was buildable
'ps_rows' -- the raw text of that field, unparsed, still here
'openings_rows' -- [{'x_ft', 'y_ft', 'w_ft', 'h_ft', 'label'}, ...]
'openings' -- the raw text of that field, unparsed, still here
'stage_matrix' -- {stage: {opt_key: value}}, see
`_parse_stage_matrix`
`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.
"""
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
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
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
else:
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['ps_rows'] = values.get('ps_rows') or ''
parsed['stage_matrix'] = _parse_stage_matrix(values, errors)
# Wind and in-place restraints are collected here and carried into the deck;
# the wind PRESSURE itself is computed on the CPython side (windload). Both
# are optional stage3 inputs, so neither adds to `errors`.
parsed['wind'] = _wind_from(values)
parsed['restraints'] = _restraints_from(values)
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 reach the deck; the
Eriksson stage3 inputs (in-place wind, restraints, mild steel, openings,
load combinations, per-stage rigging, ...) are collected on the form so
it is Eriksson-complete, but they do not enter the Stage-0 deck -- the
solve does not read them yet and a deck that carried them would imply it
did. Openings specifically stay out for a translation reason too -- see
the module docstring's "WHY OPENINGS DO NOT REACH `blockouts` YET".
`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
handling = {
'_note': ('result block; filled by panelanalysis.py from the '
'self-weight solve on structural_model.supports'),
}
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,
},
'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': {
'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': [],
'recesses': [],
},
'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'),
},
'reinforcement': reinforcement,
'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 inputs entered on the form; the '
'in-place wind case is absent (the ASCE C&C '
'pressure is computed from the wind inputs '
'when they are given)')),
'combinations': _unread('ACI 318 load combinations are built with '
'the in-place engine (Stage 3)'),
},
'handling': handling,
'in_place': _unread('the in-place wind/gravity engine is not built '
'yet (Stage 3); its result block is a placeholder, '
'never fabricated zeros'),
'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