52d94303bc
ci / test (push) Has been cancelled
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.
1593 lines
73 KiB
Python
1593 lines
73 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.
|
||
|
||
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
|