25592fa6c2
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
2320 lines
98 KiB
Python
2320 lines
98 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""The job <-> values half of the Beam Analysis page.
|
|
|
|
Host CPython 3.9+; no third-party imports; no Revit anywhere on this path.
|
|
|
|
This module converts between the two documents the page contract in
|
|
`docs/ba-web-spec.md` defines:
|
|
|
|
* the **job** -- a `beamcalc/1` JSON dict, read by
|
|
`beamcalc.model.from_job` (the beam analysis engine under
|
|
`beamcalc/src/beamcalc/`);
|
|
* the **values** -- the pagespec's flat `{key: string}` store, one key per
|
|
dotted job path (spec section 3). A scalar's key IS its job path
|
|
(`extents.length_ft`, `materials.rebar.fy_ksi`, `bearing.mu`, ...);
|
|
every row of a job list becomes `<table>.<n>.<col>` with
|
|
`<table>.<n>.present = 'true'` marking live rows (the `pa-web-spec.md`
|
|
section 3 row-table shape).
|
|
|
|
`values_from_job` flattens a job into values, filling each scalar the job
|
|
did not carry from `beamcalc.model`'s own default (and recording every fill
|
|
in the returned `defaults_why`); `job_from_values` is the inverse, building
|
|
a full job for the engine from whatever strings the page currently holds.
|
|
The round trip is the invariant (spec section 1): `from_job(job_from_values(
|
|
values_from_job(J)[0]))` reproduces `from_job(J)` dataclass field by
|
|
dataclass field, for both fixtures in `beamcalc/fixtures/`.
|
|
|
|
Section 3 of the spec says the values keys are exactly the paths `from_job`
|
|
reads. The tables below follow that map one for one, so this file drifts
|
|
only when `beamcalc/src/beamcalc/model.py` itself does -- and `model.py`
|
|
wins where they disagree.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
# `beamcalc` is plain CPython (no Revit); import it the ordinary way,
|
|
# falling back to the in-repo source tree when it is not installed.
|
|
try:
|
|
import beamcalc.model as BM
|
|
except ImportError:
|
|
_BEAMCALC_SRC = os.path.abspath(os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)),
|
|
os.pardir, 'beamcalc', 'src'))
|
|
if _BEAMCALC_SRC not in sys.path:
|
|
sys.path.insert(0, _BEAMCALC_SRC)
|
|
# A first failed import can leave a *namespace* `beamcalc` (a directory
|
|
# without the model submodule, e.g. the repo's own `beamcalc/` when its
|
|
# `src` is not on the path) behind in sys.modules, which would shadow
|
|
# the real package on retry. Drop it so the second import re-searches.
|
|
sys.modules.pop('beamcalc', None)
|
|
import beamcalc.model as BM # noqa: E402
|
|
|
|
# ------------------------------------------------------------- value types
|
|
|
|
_FLOAT = 'float'
|
|
_INT = 'int'
|
|
_STR = 'str'
|
|
_BOOL = 'bool'
|
|
_RATIO = 'ratio' # a deflection ratio: "L/240" or "240" (model.py:600-605)
|
|
|
|
# Section kinds `_section` recognises (model.py:557-573).
|
|
_L_KINDS = ('L', 'l', 'l_beam', 'spandrel', 'spandrel/_l-beam',
|
|
'spandrel / l-beam', 'spandrel_/_l-beam')
|
|
_IT_KINDS = ('inverted_t', 'it', 'inverted_tee')
|
|
|
|
|
|
class _Missing(object):
|
|
def __repr__(self):
|
|
return '<missing>'
|
|
|
|
|
|
_MISSING = _Missing()
|
|
|
|
|
|
def _get_scalar(job, dotted):
|
|
"""The value at a dotted job path, or the `_MISSING` sentinel when any
|
|
segment along the way is absent."""
|
|
node = job
|
|
for part in dotted.split('.'):
|
|
if not isinstance(node, dict) or part not in node:
|
|
return _MISSING
|
|
node = node[part]
|
|
return node
|
|
|
|
|
|
# ------------------------------------------------------------------ scalars
|
|
|
|
# Every scalar `from_job` reads, in the order it reads them
|
|
# (`beamcalc/src/beamcalc/model.py:655-766`): (values key, type, default).
|
|
# A `None` default marks a path whose engine default is NOT a constant --
|
|
# it is resolved against the rest of the job (or required): see
|
|
# `_WHY_BLANK` for the per-key sentence. Defaults that ARE constants come
|
|
# from the model module itself where one exists (Settings, Stripping,
|
|
# Rebar, StrandMaterial); the value is filled into `values` when the job
|
|
# omits it, so the page shows what the engine will use.
|
|
#
|
|
# By design NOT listed here:
|
|
# * `supports.start.width_in` / `supports.end.width_in` -- the engine
|
|
# defaults each to the section width (model.py:674-678), which is only
|
|
# resolvable against the rest of the job. `values_from_job` carries the
|
|
# job's value through when present; `job_from_values` leaves the key
|
|
# out when blank, and the engine applies the same rule.
|
|
# * `settings.shear.composite` / `settings.flexure.method` /
|
|
# `handling.handling` / `handling.erection` and friends sit in the
|
|
# fixtures but are read by nothing in `from_job`; showing an input the
|
|
# engine ignores is the silent-success failure PROJECT.md section 3.1
|
|
# forbids (ba-web-spec.md section 2 makes the same call).
|
|
SCALAR_KEYS = [
|
|
# -- top level (model.py:755, 766) ---------------------------------
|
|
('mark', _STR, ''),
|
|
('description', _STR, ''),
|
|
# -- extents (model.py:661-666) ------------------------------------
|
|
('extents.length_ft', _FLOAT, None), # required, model.py:664
|
|
('extents.section.type', _STR, 'rectangular'), # model.py:558
|
|
('extents.section.width_in', _FLOAT, None), # required, model.py:560
|
|
('extents.section.height_in', _FLOAT, None), # required, model.py:561
|
|
('extents.section.stem_width_in', _FLOAT, None), # req for L/it
|
|
('extents.section.stem_height_in', _FLOAT, None), # req for L/it
|
|
('extents.section.flange_width_in', _FLOAT, None), # req for L/it
|
|
('extents.section.flange_thickness_in', _FLOAT, None), # req for L/it
|
|
('extents.section.side', _STR, 'left'), # model.py:567
|
|
# -- materials (model.py:576-592) ----------------------------------
|
|
('materials.fc_ksi', _FLOAT, None), # required, model.py:581
|
|
('materials.fci_ksi', _FLOAT, None), # -> fc_ksi, model.py:582
|
|
('materials.wc_pcf', _FLOAT, 150.0), # model.py:583
|
|
('materials.ec_ksi', _FLOAT, None), # -> ACI Ec, model.py:584
|
|
('materials.eci_ksi', _FLOAT, None), # -> ACI Eci, model.py:585
|
|
('materials.type', _STR, 'normalweight'), # model.py:586
|
|
('materials.lambda', _FLOAT, 1.0), # model.py:587
|
|
('materials.rebar.fy_ksi', _FLOAT, 60.0), # model.py:588 / Rebar
|
|
('materials.rebar.es_ksi', _FLOAT, 29000.0), # model.py:588
|
|
('materials.trans.fy_ksi', _FLOAT, None), # -> rebar, model.py:578
|
|
('materials.trans.es_ksi', _FLOAT, None), # -> rebar, model.py:578
|
|
('materials.strand.fpu_ksi', _FLOAT, 270.0), # model.py:590
|
|
('materials.strand.ep_ksi', _FLOAT, 28500.0), # model.py:591
|
|
('materials.strand.low_relaxation', _BOOL, True), # model.py:592
|
|
# -- settings.deflection (model.py:599-614) ------------------------
|
|
('settings.deflection.total', _RATIO, 'L/240'), # model.py:142
|
|
('settings.deflection.live', _RATIO, 'L/360'), # model.py:143
|
|
('settings.deflection.ec_for_sw', _STR, 'Eci'), # model.py:144
|
|
('settings.deflection.multipliers.self_weight.erection', _FLOAT, 1.0), # model.py:146
|
|
('settings.deflection.multipliers.self_weight.construction', _FLOAT, 1.3), # model.py:146
|
|
('settings.deflection.multipliers.self_weight.end', _FLOAT, 2.0), # model.py:146
|
|
('settings.deflection.multipliers.addl_dead.construction', _FLOAT, 0.0), # model.py:148
|
|
('settings.deflection.multipliers.addl_dead.end', _FLOAT, 2.0), # model.py:148
|
|
# -- settings.phi (model.py:615-618) -------------------------------
|
|
('settings.phi.tension', _FLOAT, 0.9), # model.py:161
|
|
('settings.phi.shear', _FLOAT, 0.75), # model.py:162
|
|
('settings.phi.compression', _FLOAT, 0.65), # model.py:163
|
|
# -- settings.transfer_limits (model.py:619-623) -------------------
|
|
('settings.transfer_limits.comp_ends', _FLOAT, 0.7), # model.py:155
|
|
('settings.transfer_limits.comp_other', _FLOAT, 0.7), # model.py:156
|
|
('settings.transfer_limits.tens_ends_sqrt', _FLOAT, 6.0), # model.py:157
|
|
('settings.transfer_limits.tens_other_sqrt', _FLOAT, 6.0), # model.py:158
|
|
# -- settings.service_limits (model.py:624-626) --------------------
|
|
('settings.service_limits.comp_sustained', _FLOAT, 0.45), # model.py:159
|
|
('settings.service_limits.comp_total', _FLOAT, 0.60), # model.py:160
|
|
# -- settings.flexure (model.py:627-629) ---------------------------
|
|
('settings.flexure.ignored_distance_in', _FLOAT, 0.0), # model.py:628
|
|
('settings.flexure.strain_limit', _FLOAT, 0.004), # model.py:629
|
|
# -- settings.shear (model.py:630-632) -----------------------------
|
|
('settings.shear.dv_method', _STR, 'cg_tension'), # model.py:150
|
|
('settings.shear.min_method', _STR, 'beam_9.6.3'), # model.py:151
|
|
# -- settings.development (model.py:633-635) -----------------------
|
|
('settings.development.ktr', _FLOAT, 0.0), # model.py:634
|
|
('settings.development.mild_mult', _FLOAT, 1.0), # model.py:635
|
|
# -- settings.section_properties (model.py:636-637) ----------------
|
|
('settings.section_properties.transform', _BOOL, True), # model.py:636-637
|
|
# -- supports (model.py:670-679) -----------------------------------
|
|
('supports.start.from_start_in', _FLOAT, 0.0), # model.py:674
|
|
('supports.start.length_in', _FLOAT, 0.0), # model.py:674
|
|
('supports.start.type', _STR, 'pin'), # model.py:675
|
|
('supports.end.from_end_in', _FLOAT, 0.0), # model.py:676
|
|
('supports.end.length_in', _FLOAT, 0.0), # model.py:676
|
|
('supports.end.type', _STR, 'pin'), # model.py:677
|
|
# -- loads (model.py:761-762) --------------------------------------
|
|
('loads.self_weight_mult', _FLOAT, 1.0), # model.py:761
|
|
('loads.pattern_live', _BOOL, True), # model.py:762
|
|
# -- handling.stripping (model.py:741-751) -------------------------
|
|
('handling.stripping.multiplier', _FLOAT, 1.4), # model.py:745
|
|
('handling.stripping.face_down', _STR, 'bottom'), # model.py:746
|
|
('handling.stripping.strength', _STR, 'initial'), # model.py:747
|
|
('handling.stripping.limits', _STR, 'transfer'), # model.py:748
|
|
('handling.stripping.lift_from_start_in', _FLOAT, 0.0), # model.py:749
|
|
('handling.stripping.lift_from_end_in', _FLOAT, 0.0), # model.py:750
|
|
('handling.stripping.pick_points', _INT, 2), # model.py:751
|
|
# -- bearing (model.py:753, 763-765) -------------------------------
|
|
('bearing.analyse', _BOOL, True), # model.py:763
|
|
('bearing.mu', _FLOAT, 1.4), # model.py:764
|
|
('bearing.nu_ratio', _FLOAT, 0.2), # model.py:765
|
|
]
|
|
|
|
_SCALAR_TYPES = {key: typ for key, typ, _ in SCALAR_KEYS}
|
|
|
|
|
|
def _model_default_cite(key):
|
|
"""A terse model.py citation naming where a default lives."""
|
|
cites = {
|
|
'settings.deflection.total': 'Settings.total_defl_ratio (line 142)',
|
|
'settings.deflection.live': 'Settings.live_defl_ratio (line 143)',
|
|
'settings.deflection.ec_for_sw': 'Settings.ec_for_sw_defl (line 144)',
|
|
'settings.deflection.multipliers.self_weight.erection':
|
|
'Settings.sw_mult (lines 145-146)',
|
|
'settings.deflection.multipliers.self_weight.construction':
|
|
'Settings.sw_mult (lines 145-146)',
|
|
'settings.deflection.multipliers.self_weight.end':
|
|
'Settings.sw_mult (lines 145-146)',
|
|
'settings.deflection.multipliers.addl_dead.construction':
|
|
'Settings.addl_dead_mult (lines 147-148)',
|
|
'settings.deflection.multipliers.addl_dead.end':
|
|
'Settings.addl_dead_mult (lines 147-148)',
|
|
'settings.phi.tension': 'Settings.phi_tension (line 161)',
|
|
'settings.phi.shear': 'Settings.phi_shear (line 162)',
|
|
'settings.phi.compression': 'Settings.phi_compression (line 163)',
|
|
'settings.transfer_limits.comp_ends': 'Settings (line 155)',
|
|
'settings.transfer_limits.comp_other': 'Settings (line 156)',
|
|
'settings.transfer_limits.tens_ends_sqrt': 'Settings (line 157)',
|
|
'settings.transfer_limits.tens_other_sqrt': 'Settings (line 158)',
|
|
'settings.service_limits.comp_sustained': 'Settings (line 159)',
|
|
'settings.service_limits.comp_total': 'Settings (line 160)',
|
|
'settings.shear.dv_method': 'Settings.dv_method (line 150)',
|
|
'settings.shear.min_method': 'Settings.min_shear_method (line 151)',
|
|
'settings.section_properties.transform': 'model.py:636-637',
|
|
'materials.rebar.fy_ksi': 'Rebar (lines 62-64)',
|
|
'materials.rebar.es_ksi': 'Rebar (lines 62-64)',
|
|
'materials.strand.fpu_ksi': 'StrandMaterial (lines 68-71)',
|
|
'materials.strand.ep_ksi': 'StrandMaterial (lines 68-71)',
|
|
'materials.strand.low_relaxation': 'StrandMaterial (lines 68-71)',
|
|
'handling.stripping.multiplier': 'Stripping (line 441)',
|
|
'handling.stripping.face_down': 'Stripping.face_down (line 442)',
|
|
'handling.stripping.strength': 'Stripping.strength (line 443)',
|
|
'handling.stripping.limits': 'Stripping.limits (line 444)',
|
|
'handling.stripping.lift_from_start_in': 'Stripping (line 445)',
|
|
'handling.stripping.lift_from_end_in': 'Stripping (line 446)',
|
|
'handling.stripping.pick_points': 'Stripping.pick_points (line 447)',
|
|
'extents.section.type': 'model.py:558 fallback',
|
|
'extents.section.side': 'model.py:567 fallback',
|
|
'materials.type': 'model.py:586 fallback',
|
|
'materials.wc_pcf': 'model.py:583 fallback',
|
|
'materials.lambda': 'model.py:587 fallback',
|
|
'supports.start.type': 'model.py:675 fallback',
|
|
'supports.end.type': 'model.py:677 fallback',
|
|
'supports.start.from_start_in': 'model.py:674 fallback',
|
|
'supports.start.length_in': 'model.py:674 fallback',
|
|
'supports.end.from_end_in': 'model.py:676 fallback',
|
|
'supports.end.length_in': 'model.py:676 fallback',
|
|
'loads.self_weight_mult': 'model.py:761 fallback',
|
|
'loads.pattern_live': 'model.py:762 fallback',
|
|
'bearing.analyse': 'model.py:763 fallback',
|
|
'bearing.mu': 'model.py:764 fallback',
|
|
'bearing.nu_ratio': 'model.py:765 fallback',
|
|
'settings.flexure.ignored_distance_in': 'model.py:628 fallback',
|
|
'settings.flexure.strain_limit': 'model.py:629 fallback',
|
|
'settings.development.ktr': 'model.py:634 fallback',
|
|
'settings.development.mild_mult': 'model.py:635 fallback',
|
|
}
|
|
return cites.get(key, 'model.py from_job')
|
|
|
|
|
|
# Sentences for the `None`-default (job-dependent or required) keys, naming
|
|
# exactly what the engine does when the job omits them.
|
|
_WHY_BLANK = {
|
|
'extents.length_ft':
|
|
'model.py requires extents.length_ft (or extents.length_in) and '
|
|
'raises JobError without one (model.py:662-665); the job carried '
|
|
'neither, so this is blank',
|
|
'extents.section.width_in':
|
|
'model.py requires extents.section.width_in for every section kind '
|
|
'(model.py:560); blank because the job carried none',
|
|
'extents.section.height_in':
|
|
'model.py requires extents.section.height_in for every section kind '
|
|
'(model.py:561); blank because the job carried none',
|
|
'extents.section.stem_width_in':
|
|
'required only for an L / inverted-T section (model.py:563-572); '
|
|
'blank because the job carried none',
|
|
'extents.section.stem_height_in':
|
|
'required only for an L / inverted-T section (model.py:563-572); '
|
|
'blank because the job carried none',
|
|
'extents.section.flange_width_in':
|
|
'required only for an L / inverted-T section (model.py:563-572); '
|
|
'blank because the job carried none',
|
|
'extents.section.flange_thickness_in':
|
|
'required only for an L / inverted-T section (model.py:563-572); '
|
|
'blank because the job carried none',
|
|
'materials.fc_ksi':
|
|
'model.py requires materials.fc_ksi and raises JobError without it '
|
|
'(model.py:581); blank because the job carried none',
|
|
'materials.fci_ksi':
|
|
"the engine reads f'ci as f'c when the job omits fci_ksi "
|
|
'(model.py:582); blank means the job did not carry it',
|
|
'materials.ec_ksi':
|
|
'the engine computes Ec from ACI 19.2.2.1(a) when the job omits '
|
|
'ec_ksi (Materials.ec_ksi = None, model.py:79-80, 93-94)',
|
|
'materials.eci_ksi':
|
|
'the engine computes Eci from ACI 19.2.2.1(a) when the job omits '
|
|
'eci_ksi (Materials.eci_ksi = None, model.py:80, 97-98)',
|
|
'materials.trans.fy_ksi':
|
|
'the engine takes the transverse rebar from materials.rebar when '
|
|
'the job has no materials.trans (model.py:578); blank means the job '
|
|
'did not carry a trans block',
|
|
'materials.trans.es_ksi':
|
|
'the engine takes the transverse rebar from materials.rebar when '
|
|
'the job has no materials.trans (model.py:578); blank means the job '
|
|
'did not carry a trans block',
|
|
'handling.stripping.multiplier':
|
|
'the job carries no handling.stripping block, so the engine leaves '
|
|
'stripping unset (Beam.stripping = None, model.py:741-751); blank, '
|
|
'and Run writes no stripping block either',
|
|
'handling.stripping.face_down':
|
|
'the job carries no handling.stripping block, so the engine leaves '
|
|
'stripping unset (Beam.stripping = None, model.py:741-751); blank, '
|
|
'and Run writes no stripping block either',
|
|
'handling.stripping.strength':
|
|
'the job carries no handling.stripping block, so the engine leaves '
|
|
'stripping unset (Beam.stripping = None, model.py:741-751); blank, '
|
|
'and Run writes no stripping block either',
|
|
'handling.stripping.limits':
|
|
'the job carries no handling.stripping block, so the engine leaves '
|
|
'stripping unset (Beam.stripping = None, model.py:741-751); blank, '
|
|
'and Run writes no stripping block either',
|
|
'handling.stripping.lift_from_start_in':
|
|
'the job carries no handling.stripping block, so the engine leaves '
|
|
'stripping unset (Beam.stripping = None, model.py:741-751); blank, '
|
|
'and Run writes no stripping block either',
|
|
'handling.stripping.lift_from_end_in':
|
|
'the job carries no handling.stripping block, so the engine leaves '
|
|
'stripping unset (Beam.stripping = None, model.py:741-751); blank, '
|
|
'and Run writes no stripping block either',
|
|
'handling.stripping.pick_points':
|
|
'the job carries no handling.stripping block, so the engine leaves '
|
|
'stripping unset (Beam.stripping = None, model.py:741-751); blank, '
|
|
'and Run writes no stripping block either',
|
|
}
|
|
|
|
|
|
def _why_filled(key, default):
|
|
"""One sentence for a values key filled from a model.py default."""
|
|
return ('no {0} in the job; shown with the model.py default {1!r} '
|
|
'({2})'.format(key, default, _model_default_cite(key)))
|
|
|
|
|
|
# ------------------------------------------------------------------ tables
|
|
|
|
def _case_columns():
|
|
"""One float column per load case, in `beamcalc.model.CASES` order --
|
|
read from the module, never copied (ba-web-spec.md section 3)."""
|
|
return [(case, _FLOAT, 0.0) for case in BM.CASES]
|
|
|
|
|
|
def _line_case_columns():
|
|
"""Line loads taper: `<case>_start` and `<case>_end` per case."""
|
|
out = []
|
|
for case in BM.CASES:
|
|
out.append(('{0}_start'.format(case), _FLOAT, 0.0))
|
|
out.append(('{0}_end'.format(case), _FLOAT, 0.0))
|
|
return out
|
|
|
|
|
|
# The eight row tables from ba-web-spec.md section 3, in spec order: table
|
|
# name -> job list path and columns in order. Each column is
|
|
# (values suffix, type, default); a `None` column default marks one the
|
|
# engine requires or treats as optional-absent (`size`, `spacing_in`,
|
|
# `area_in2`, `force_k`). The two combo tables fall back to
|
|
# `model.DEFAULT_STRENGTH` / `model.DEFAULT_SERVICE` when the job carries no
|
|
# rows at all (spec section 3; model.py:685-686), recorded in
|
|
# `defaults_why` because those rows are values the job never supplied.
|
|
TABLES = {
|
|
'point_loads': {
|
|
'path': ('loads', 'point'),
|
|
'columns': [
|
|
('id', _STR, ''),
|
|
('x_in', _FLOAT, 0.0),
|
|
('from', _STR, 'start'),
|
|
('ecc_in', _FLOAT, 0.0),
|
|
] + _case_columns() + [
|
|
('description', _STR, ''),
|
|
],
|
|
},
|
|
'line_loads': {
|
|
'path': ('loads', 'line'),
|
|
'pair_cases': True, # per-case pairs rebuild as [start, end]
|
|
'columns': [
|
|
('id', _STR, ''),
|
|
('x0_in', _FLOAT, 0.0),
|
|
('x0_from', _STR, 'start'),
|
|
('x1_in', _FLOAT, 0.0),
|
|
('x1_from', _STR, 'end'),
|
|
('ecc0_in', _FLOAT, 0.0),
|
|
('ecc1_in', _FLOAT, 0.0),
|
|
] + _line_case_columns() + [
|
|
('description', _STR, ''),
|
|
],
|
|
},
|
|
'moments': {
|
|
'path': ('loads', 'moments'),
|
|
'columns': [
|
|
('id', _STR, ''),
|
|
('x_in', _FLOAT, 0.0),
|
|
('from', _STR, 'start'),
|
|
] + _case_columns(),
|
|
},
|
|
'combos_strength': {
|
|
'path': ('combos', 'strength'),
|
|
'columns': [
|
|
('label', _STR, ''),
|
|
('D', _FLOAT, 0.0),
|
|
('L', _FLOAT, 0.0),
|
|
('R', _FLOAT, 0.0),
|
|
('W', _FLOAT, 0.0),
|
|
('E', _FLOAT, 0.0),
|
|
('include', _BOOL, True),
|
|
],
|
|
'default_rows': BM.DEFAULT_STRENGTH,
|
|
'default_rows_why': ("the job carries no strength rows; the values "
|
|
"are model.py's own DEFAULT_STRENGTH "
|
|
"(beamcalc/src/beamcalc/model.py, lines "
|
|
"533-542)"),
|
|
},
|
|
'combos_service': {
|
|
'path': ('combos', 'service'),
|
|
'columns': [
|
|
('label', _STR, ''),
|
|
('D', _FLOAT, 0.0),
|
|
('L', _FLOAT, 0.0),
|
|
('R', _FLOAT, 0.0),
|
|
('W', _FLOAT, 0.0),
|
|
('E', _FLOAT, 0.0),
|
|
('include', _BOOL, True),
|
|
],
|
|
'default_rows': BM.DEFAULT_SERVICE,
|
|
'default_rows_why': ("the job carries no service rows; the values "
|
|
"are model.py's own DEFAULT_SERVICE "
|
|
"(beamcalc/src/beamcalc/model.py, lines "
|
|
"543-554)"),
|
|
},
|
|
'bars': {
|
|
'path': ('reinforcement', 'bars'),
|
|
'columns': [
|
|
('size', _STR, ''), # required, model.py:714
|
|
('rows', _INT, 1),
|
|
('cols', _INT, 1),
|
|
('left_in', _FLOAT, 2.0),
|
|
('right_in', _FLOAT, 2.0),
|
|
('top_in', _FLOAT, 2.0),
|
|
('bottom_in', _FLOAT, 2.0),
|
|
('start_in', _FLOAT, 0.0),
|
|
('end_in', _FLOAT, 0.0),
|
|
('start_cond', _STR, 'straight'),
|
|
('end_cond', _STR, 'straight'),
|
|
('area_in2', _FLOAT, None), # optional, model.py:720
|
|
],
|
|
},
|
|
'strands': {
|
|
'path': ('reinforcement', 'strands'),
|
|
'columns': [
|
|
('ids', _STR, ''),
|
|
('size', _STR, ''), # required, model.py:723
|
|
('pull_pct', _FLOAT, 70.0),
|
|
('rows', _INT, 1),
|
|
('cols', _INT, 1),
|
|
('left_in', _FLOAT, 2.0),
|
|
('right_in', _FLOAT, 2.0),
|
|
('first_row_in', _FLOAT, 2.0),
|
|
('spacing_in', _FLOAT, 2.0),
|
|
('force_k', _FLOAT, None), # optional, model.py:729
|
|
],
|
|
},
|
|
'transverse': {
|
|
'path': ('reinforcement', 'transverse'),
|
|
'columns': [
|
|
('size', _STR, ''), # required, model.py:732
|
|
('legs', _INT, 2),
|
|
('first_in', _FLOAT, 0.0),
|
|
('from', _STR, 'start'),
|
|
('spacing_in', _FLOAT, None), # required, model.py:735
|
|
('length_in', _FLOAT, 0.0),
|
|
('qty', _INT, 0),
|
|
('symmetric', _BOOL, True),
|
|
('use', _STR, 'Av'),
|
|
('area_in2', _FLOAT, None), # optional, model.py:739
|
|
],
|
|
},
|
|
}
|
|
|
|
|
|
# ------------------------------------------------------------------ formats
|
|
|
|
def format_float(v):
|
|
"""A float as a round-trip-safe string: `repr()` with a trailing `.0`
|
|
trimmed. A `%g` format is NOT acceptable here -- it would turn
|
|
`17.916667` into `17.9167` and quietly change the beam."""
|
|
s = repr(float(v))
|
|
if s.endswith('.0'):
|
|
s = s[:-2]
|
|
return s
|
|
|
|
|
|
def _parse_float(key, text):
|
|
try:
|
|
return float(text.strip())
|
|
except ValueError:
|
|
raise ValueError('{0}: not a number: {1!r}'.format(key, text))
|
|
|
|
|
|
def _parse_int(key, text):
|
|
try:
|
|
return int(text.strip())
|
|
except ValueError:
|
|
raise ValueError('{0}: not a number: {1!r}'.format(key, text))
|
|
|
|
|
|
def _parse_bool(key, text):
|
|
low = text.strip().lower()
|
|
if low == 'true':
|
|
return True
|
|
if low == 'false':
|
|
return False
|
|
raise ValueError('{0}: not true or false: {1!r}'.format(key, text))
|
|
|
|
|
|
def _cell_to_string(typ, value):
|
|
"""A raw job value as a values string. `value` is a Python object from
|
|
`json.load`, so bools must be checked before ints."""
|
|
if value is None:
|
|
return ''
|
|
if isinstance(value, bool):
|
|
return 'true' if value else 'false'
|
|
if typ == _FLOAT or isinstance(value, float):
|
|
return format_float(value)
|
|
if isinstance(value, int):
|
|
return str(value)
|
|
return str(value)
|
|
|
|
|
|
def _scalar_to_string(key, value):
|
|
"""The string form of a job scalar. A `ratio` key keeps its job
|
|
spelling (`'L/480'` in the fixtures, or a bare denominator) -- both are
|
|
valid page values (ba-web-spec.md section 3: `L/240` or `240`), and
|
|
model.py reads either (model.py:600-605)."""
|
|
typ = _SCALAR_TYPES[key]
|
|
if typ == _RATIO:
|
|
if isinstance(value, str):
|
|
return value
|
|
return format_float(float(value))
|
|
if typ == _FLOAT and not isinstance(value, bool):
|
|
return format_float(float(value))
|
|
return _cell_to_string(typ, value)
|
|
|
|
|
|
def _ensure(job, parts):
|
|
"""Descend into `parts`, creating dicts, and return the final dict."""
|
|
node = job
|
|
for part in parts:
|
|
nxt = node.get(part)
|
|
if not isinstance(nxt, dict):
|
|
nxt = {}
|
|
node[part] = nxt
|
|
node = nxt
|
|
return node
|
|
|
|
|
|
# ------------------------------------------------------------------ flatten
|
|
|
|
# A line-load row in the job may carry a per-case *scalar* (`deadc: 3.05`,
|
|
# the fixtures' uniform-load shorthand) or a *[start, end] pair* (a taper,
|
|
# model.py:646-649) -- `_cases` accepts both. The page's columns are always
|
|
# the pair, so flattening splits either form across `<case>_start` /
|
|
# `<case>_end`. The rebuild merges the pair back into a single scalar when
|
|
# both ends are equal, so a fixture-written uniform load round-trips to the
|
|
# same one-value shape (a page-typed taper stays an explicit [start, end]).
|
|
def _explode_line_load_row(row):
|
|
row = dict(row)
|
|
for case in BM.CASES:
|
|
v = row.pop(case, None)
|
|
if v is None:
|
|
continue
|
|
if isinstance(v, (list, tuple)):
|
|
row['{0}_start'.format(case)] = v[0]
|
|
row['{0}_end'.format(case)] = v[-1]
|
|
else:
|
|
row['{0}_start'.format(case)] = v
|
|
row['{0}_end'.format(case)] = v
|
|
return row
|
|
|
|
|
|
def values_from_job(job):
|
|
"""Flatten a `beamcalc/1` job into the page's flat string values.
|
|
|
|
Returns `(values, defaults_why)`. Every `SCALAR_KEYS` entry gets a
|
|
string value -- the job's value when present, else the model.py default
|
|
with a `defaults_why[key]` sentence naming it (blank for a key whose
|
|
engine default is job-dependent or required, with a sentence saying
|
|
so). Every job list becomes `<table>.<n>.<col>` rows with
|
|
`<table>.<n>.present = 'true'`; the combination tables fall back to
|
|
`model.DEFAULT_STRENGTH` / `model.DEFAULT_SERVICE` when the job carries
|
|
no rows, also recorded in `defaults_why`.
|
|
"""
|
|
values = {}
|
|
defaults_why = {}
|
|
|
|
ext = job.get('extents') or {}
|
|
stripping_block = _get_scalar(job, 'handling.stripping')
|
|
has_stripping = isinstance(stripping_block, dict)
|
|
|
|
for key, typ, default in SCALAR_KEYS:
|
|
if key == 'extents.length_ft':
|
|
if 'length_ft' in ext:
|
|
raw = ext['length_ft']
|
|
elif 'length_in' in ext:
|
|
raw = float(ext['length_in']) / 12.0
|
|
else:
|
|
raw = _MISSING
|
|
else:
|
|
raw = _get_scalar(job, key)
|
|
|
|
if raw is _MISSING or raw is None:
|
|
# Stripping: when the whole block is absent the engine leaves
|
|
# Beam.stripping None; writing the dataclass defaults back would
|
|
# silently create a stripping stage, so those stay blank too.
|
|
if key.startswith('handling.stripping.') and not has_stripping:
|
|
values[key] = ''
|
|
defaults_why[key] = _WHY_BLANK[key]
|
|
elif default is None or default == '':
|
|
values[key] = ''
|
|
why = _WHY_BLANK.get(key)
|
|
if why is None:
|
|
why = ('no default for {0} in model.py; the job carried '
|
|
'none, so this is blank'.format(key))
|
|
defaults_why[key] = why
|
|
else:
|
|
values[key] = _scalar_to_string(key, default)
|
|
defaults_why[key] = _why_filled(key, default)
|
|
else:
|
|
values[key] = _scalar_to_string(key, raw)
|
|
|
|
# Support widths: the engine defaults each to the section width
|
|
# (model.py:674-678), so they are carried through only when the job has
|
|
# them; a blank means "the engine rule applies" and job_from_values
|
|
# leaves the key out. They are not SCALAR_KEYS entries (their default is
|
|
# not a constant), but the values keys are their job paths.
|
|
supports = job.get('supports') or {}
|
|
for side in ('start', 'end'):
|
|
block = supports.get(side) or {}
|
|
if isinstance(block, dict) and block.get('width_in') is not None:
|
|
key = 'supports.{0}.width_in'.format(side)
|
|
values[key] = format_float(float(block['width_in']))
|
|
|
|
# -- row tables ----------------------------------------------------
|
|
for name, meta in TABLES.items():
|
|
rows = _get_scalar(job, '.'.join(meta['path']))
|
|
if rows is _MISSING or rows is None:
|
|
if meta.get('default_rows') is not None:
|
|
rows = meta['default_rows']
|
|
defaults_why[name] = meta['default_rows_why']
|
|
else:
|
|
rows = []
|
|
rows = list(rows)
|
|
if not rows:
|
|
# An empty job list still leaves one all-blank row in values:
|
|
# a table with no rows at all cannot be edited on the page
|
|
# (pa-web-spec.md section 3 pads to the table's minimum rows).
|
|
# `job_from_values` drops a row that is entirely blank, so the
|
|
# round trip still reproduces the empty list.
|
|
rows = [{}]
|
|
for n, row in enumerate(rows, 1):
|
|
prefix = '{0}.{1}'.format(name, n)
|
|
values[prefix + '.present'] = 'true'
|
|
if isinstance(row, dict) and meta.get('pair_cases'):
|
|
row = _explode_line_load_row(row)
|
|
for col, typ, default in meta['columns']:
|
|
raw = row.get(col) if isinstance(row, dict) else None
|
|
values[prefix + '.' + col] = _cell_to_string(typ, raw)
|
|
|
|
return values, defaults_why
|
|
|
|
|
|
# ------------------------------------------------------------------ rebuild
|
|
|
|
def job_from_values(values):
|
|
"""Rebuild a `beamcalc/1` job from the page's flat string values.
|
|
|
|
The inverse of `values_from_job`. A blank string omits that key from
|
|
the job, so `from_job` applies its own default; a string that will not
|
|
parse raises `ValueError` whose message starts with the values key
|
|
(`"extents.length_ft: not a number: 'abc'"`).
|
|
"""
|
|
job = {'schema': BM.SCHEMA}
|
|
|
|
# -- top-level strings ---------------------------------------------
|
|
for key in ('mark', 'description'):
|
|
text = values.get(key, '')
|
|
if text:
|
|
job[key] = text
|
|
|
|
# -- settings ------------------------------------------------------
|
|
_build_settings(values, job)
|
|
# -- extents -------------------------------------------------------
|
|
_build_extents(values, job)
|
|
# -- materials -----------------------------------------------------
|
|
_build_materials(values, job)
|
|
# -- supports ------------------------------------------------------
|
|
_build_supports(values, job)
|
|
# -- loads ---------------------------------------------------------
|
|
_build_loads(values, job)
|
|
# -- handling.stripping --------------------------------------------
|
|
_build_stripping(values, job)
|
|
# -- bearing -------------------------------------------------------
|
|
_build_bearing(values, job)
|
|
# -- row tables ----------------------------------------------------
|
|
_build_tables(values, job)
|
|
|
|
return job
|
|
|
|
|
|
def _build_settings(values, job):
|
|
"""One `settings.*` scalar, written where model.py reads it. The two
|
|
deflection ratios go back as plain denominators (model.py:600-605); the
|
|
multiplier stages nest under `deflection.multipliers.<kind>`. Blank ->
|
|
key stays out of the job."""
|
|
settings = {}
|
|
for key, typ, default in SCALAR_KEYS:
|
|
if not key.startswith('settings.'):
|
|
continue
|
|
text = values.get(key, '')
|
|
if not text:
|
|
continue
|
|
tail = key[len('settings.'):] # e.g. phi.tension
|
|
if tail.startswith('deflection.'):
|
|
sub = tail[len('deflection.'):]
|
|
node = settings.setdefault('deflection', {})
|
|
if sub.startswith('multipliers.'):
|
|
rest = sub[len('multipliers.'):]
|
|
kind, stage = rest.split('.', 1)
|
|
mult = node.setdefault('multipliers', {})
|
|
mult.setdefault(kind, {})[stage] = _parse_float(key, text)
|
|
elif sub in ('total', 'live'):
|
|
# Keep the page's own spelling ("L/480" or "480"); the model
|
|
# reads either (model.py:600-605), and keeping it makes a
|
|
# second flatten/rebuild cycle byte-identical.
|
|
node[sub] = text
|
|
else:
|
|
node[sub] = text # ec_for_sw
|
|
continue
|
|
parts = tail.split('.')
|
|
node = _ensure(settings, parts[:-1])
|
|
leaf = parts[-1]
|
|
if typ == _BOOL:
|
|
node[leaf] = _parse_bool(key, text)
|
|
elif typ == _FLOAT:
|
|
node[leaf] = _parse_float(key, text)
|
|
else:
|
|
node[leaf] = text
|
|
if settings:
|
|
job['settings'] = settings
|
|
|
|
|
|
def _build_extents(values, job):
|
|
"""The extents block: length plus the section, built per section kind
|
|
(model.py:557-573). Blank geometry keys stay out -- a rectangular job
|
|
never carries the stem/flange fields, so `_section` cannot misread
|
|
them. `extents.length_ft` goes back as `length_ft` (the model converts
|
|
to inches, model.py:664-665)."""
|
|
sec_type = values.get('extents.section.type', '')
|
|
if not sec_type:
|
|
return
|
|
kind = sec_type.strip().lower().replace(' ', '_')
|
|
|
|
section = {'type': sec_type}
|
|
for leaf in ('width_in', 'height_in'):
|
|
text = values.get('extents.section.' + leaf, '')
|
|
if text:
|
|
section[leaf] = _parse_float('extents.section.' + leaf, text)
|
|
if kind in _L_KINDS or kind in _IT_KINDS:
|
|
for leaf in ('stem_width_in', 'stem_height_in', 'flange_width_in',
|
|
'flange_thickness_in'):
|
|
text = values.get('extents.section.' + leaf, '')
|
|
if text:
|
|
section[leaf] = _parse_float('extents.section.' + leaf, text)
|
|
if kind in _L_KINDS:
|
|
side = values.get('extents.section.side', '')
|
|
if side:
|
|
section['side'] = side
|
|
|
|
block = {'section': section}
|
|
len_text = values.get('extents.length_ft', '')
|
|
if len_text:
|
|
block['length_ft'] = _parse_float('extents.length_ft', len_text)
|
|
job['extents'] = block
|
|
|
|
|
|
def _build_materials(values, job):
|
|
"""The materials block (model.py:576-592). Written only when at least
|
|
one key was filled, so an untouched job never gains a half-empty
|
|
`materials` that changes what `from_job` reads."""
|
|
block = {}
|
|
simple = {
|
|
'materials.fc_ksi': 'fc_ksi',
|
|
'materials.fci_ksi': 'fci_ksi',
|
|
'materials.wc_pcf': 'wc_pcf',
|
|
'materials.ec_ksi': 'ec_ksi',
|
|
'materials.eci_ksi': 'eci_ksi',
|
|
'materials.lambda': 'lambda',
|
|
}
|
|
for key, leaf in simple.items():
|
|
text = values.get(key, '')
|
|
if text:
|
|
block[leaf] = _parse_float(key, text)
|
|
typ = values.get('materials.type', '')
|
|
if typ:
|
|
block['type'] = typ
|
|
if not block:
|
|
return
|
|
|
|
for group, keys in (
|
|
('rebar', (('materials.rebar.fy_ksi', 'fy_ksi'),
|
|
('materials.rebar.es_ksi', 'es_ksi'))),
|
|
('trans', (('materials.trans.fy_ksi', 'fy_ksi'),
|
|
('materials.trans.es_ksi', 'es_ksi'))),
|
|
('strand', (('materials.strand.fpu_ksi', 'fpu_ksi'),
|
|
('materials.strand.ep_ksi', 'ep_ksi'),
|
|
('materials.strand.low_relaxation',
|
|
'low_relaxation')))):
|
|
sub = {}
|
|
for key, leaf in keys:
|
|
text = values.get(key, '')
|
|
if not text:
|
|
continue
|
|
if leaf == 'low_relaxation':
|
|
sub[leaf] = _parse_bool(key, text)
|
|
else:
|
|
sub[leaf] = _parse_float(key, text)
|
|
if sub:
|
|
block[group] = sub
|
|
|
|
job['materials'] = block
|
|
|
|
|
|
def _build_supports(values, job):
|
|
"""The two support blocks. `width_in` is carried through values only
|
|
when the job had it (see the SCALAR_KEYS note); blank -> left out, and
|
|
the engine fills the section width (model.py:674-678)."""
|
|
offset_key = {'start': 'supports.start.from_start_in',
|
|
'end': 'supports.end.from_end_in'}
|
|
blocks = {}
|
|
for side in ('start', 'end'):
|
|
block = {}
|
|
for key in (offset_key[side],
|
|
'supports.{0}.length_in'.format(side),
|
|
'supports.{0}.type'.format(side),
|
|
'supports.{0}.width_in'.format(side)):
|
|
text = values.get(key, '')
|
|
if not text:
|
|
continue
|
|
leaf = key.rsplit('.', 1)[1]
|
|
if leaf == 'type':
|
|
block['type'] = text
|
|
else:
|
|
block[leaf] = _parse_float(key, text)
|
|
if block:
|
|
blocks[side] = block
|
|
if blocks:
|
|
job['supports'] = blocks
|
|
|
|
|
|
def _build_loads(values, job):
|
|
block = {}
|
|
sw = values.get('loads.self_weight_mult', '')
|
|
if sw:
|
|
block['self_weight_mult'] = _parse_float('loads.self_weight_mult', sw)
|
|
pl = values.get('loads.pattern_live', '')
|
|
if pl:
|
|
block['pattern_live'] = _parse_bool('loads.pattern_live', pl)
|
|
if block:
|
|
job['loads'] = block
|
|
|
|
|
|
def _build_stripping(values, job):
|
|
"""`handling.stripping` only (model.py:741-751); `handling.handling`
|
|
and `handling.erection` are read by nothing and never shown (spec
|
|
section 2). Written only when at least one key is filled, so a job the
|
|
gatherer never gave a stripping block keeps `Beam.stripping = None`
|
|
instead of gaining a default stage."""
|
|
block = {}
|
|
for key, typ, default in SCALAR_KEYS:
|
|
if not key.startswith('handling.stripping.'):
|
|
continue
|
|
text = values.get(key, '')
|
|
if not text:
|
|
continue
|
|
leaf = key.rsplit('.', 1)[1]
|
|
if typ == _INT:
|
|
block[leaf] = _parse_int(key, text)
|
|
elif typ == _FLOAT:
|
|
block[leaf] = _parse_float(key, text)
|
|
else:
|
|
block[leaf] = text
|
|
if block:
|
|
handling = job.setdefault('handling', {})
|
|
handling['stripping'] = block
|
|
|
|
|
|
def _build_bearing(values, job):
|
|
block = {}
|
|
analyse = values.get('bearing.analyse', '')
|
|
if analyse:
|
|
block['analyse'] = _parse_bool('bearing.analyse', analyse)
|
|
for key in ('bearing.mu', 'bearing.nu_ratio'):
|
|
text = values.get(key, '')
|
|
if text:
|
|
block[key.rsplit('.', 1)[1]] = _parse_float(key, text)
|
|
if block:
|
|
job['bearing'] = block
|
|
|
|
|
|
def _present_rows(values, name):
|
|
"""Row numbers whose `<table>.<n>.present` is exactly 'true', ascending
|
|
(the pa-web-spec.md section 3 read rule; row numbers are never reused,
|
|
so this is a set of increasing ids)."""
|
|
out = []
|
|
for key, value in values.items():
|
|
m = re.match(r'{0}\.(\d+)\.present$'.format(name), key)
|
|
if m and value == 'true':
|
|
out.append(int(m.group(1)))
|
|
out.sort()
|
|
return out
|
|
|
|
|
|
def _merge_line_load_row(row):
|
|
"""Rebuild a line-load row's per-case pairs the way the model reads
|
|
them (model.py:641-652): a case whose start and end values are equal is
|
|
a uniform load and goes back as one scalar (`deadc: 3.05`), exactly the
|
|
fixture shape; a taper stays an explicit `[start, end]` list."""
|
|
row = dict(row)
|
|
for case in BM.CASES:
|
|
start_key = '{0}_start'.format(case)
|
|
end_key = '{0}_end'.format(case)
|
|
s = row.pop(start_key, None)
|
|
e = row.pop(end_key, None)
|
|
if s is None and e is None:
|
|
continue
|
|
if s is None:
|
|
s = e
|
|
if e is None:
|
|
e = s
|
|
if float(s) == float(e):
|
|
row[case] = float(s)
|
|
else:
|
|
row[case] = [float(s), float(e)]
|
|
return row
|
|
|
|
|
|
def _build_tables(values, job):
|
|
"""Rebuild every job list from its present rows, in row-number order.
|
|
Blank cells are omitted per column, so the engine's default applies; a
|
|
blank cell whose column the engine REQUIRES (`size`, `spacing_in`)
|
|
therefore fails loudly in `from_job` rather than running a wrong beam.
|
|
Per-case line-load pairs are rebuilt as `[start, end]` lists -- or as a
|
|
single scalar when both ends are equal -- which is what model.py:641-652
|
|
expects."""
|
|
for name, meta in TABLES.items():
|
|
rows = []
|
|
for n in _present_rows(values, name):
|
|
row = {}
|
|
for col, typ, default in meta['columns']:
|
|
key = '{0}.{1}.{2}'.format(name, n, col)
|
|
text = values.get(key, '')
|
|
if not text:
|
|
continue
|
|
if typ == _FLOAT:
|
|
row[col] = _parse_float(key, text)
|
|
elif typ == _INT:
|
|
row[col] = _parse_int(key, text)
|
|
elif typ == _BOOL:
|
|
row[col] = _parse_bool(key, text)
|
|
else:
|
|
row[col] = text
|
|
if meta.get('pair_cases'):
|
|
row = _merge_line_load_row(row)
|
|
if row:
|
|
rows.append(row)
|
|
node = _ensure(job, list(meta['path'][:-1]))
|
|
node[meta['path'][-1]] = rows
|
|
|
|
|
|
# ------------------------------------------------------------------ compare
|
|
|
|
def beam_equal(a, b):
|
|
"""Compare two `Beam` dataclasses field by field.
|
|
|
|
Returns the list of differing field paths (`[]` = equal). Floats compare
|
|
within 1e-9; container fields (load rows, bar groups, combos) are walked
|
|
element by element, so a difference reports the exact path
|
|
(`strength_combos.2.D`).
|
|
"""
|
|
ad = dataclasses.asdict(a)
|
|
bd = dataclasses.asdict(b)
|
|
out = []
|
|
|
|
def _walk(aa, bb, path):
|
|
if isinstance(aa, dict) and isinstance(bb, dict):
|
|
for k in sorted(set(aa) | set(bb)):
|
|
if k not in aa or k not in bb:
|
|
out.append('.'.join(path + [str(k)]))
|
|
continue
|
|
_walk(aa[k], bb[k], path + [str(k)])
|
|
elif isinstance(aa, list) and isinstance(bb, list):
|
|
if len(aa) != len(bb):
|
|
out.append('.'.join(path + ['<len {0} vs {1}>'.format(
|
|
len(aa), len(bb))]))
|
|
return
|
|
for i, (x, y) in enumerate(zip(aa, bb)):
|
|
_walk(x, y, path + [str(i)])
|
|
elif isinstance(aa, float) or isinstance(bb, float):
|
|
try:
|
|
if abs(float(aa) - float(bb)) > 1e-9:
|
|
out.append('.'.join(path))
|
|
except (TypeError, ValueError):
|
|
if aa != bb:
|
|
out.append('.'.join(path))
|
|
else:
|
|
if aa != bb:
|
|
out.append('.'.join(path))
|
|
|
|
_walk(ad, bd, [])
|
|
return out
|
|
|
|
|
|
# ------------------------------------------------------------------ page spec
|
|
#
|
|
# The second half of the page contract (`docs/ba-web-spec.md` sections 1-4,
|
|
# whose own section 1 inherits `pa-web-spec.md` section 1's top-level shape):
|
|
# the five `page_<id>(values, reads)` block lists and the single JSON
|
|
# document `build_pagespec()` returns for `tools/ba_web.py` (the server, not
|
|
# yet written -- it will call this exactly the way `tools/pa_web.py` calls
|
|
# `vibe.pa_pagespec.build_pagespec`, spec section 5).
|
|
#
|
|
# Output shapes mirror `WillTools.extension/lib/vibe/pa_pagespec.py` -- the
|
|
# Panel Analysis builder the shared front end (`tools/pa_web/static/app.js`)
|
|
# was written against and consumes for both tools -- exactly: `field` /
|
|
# `editable_table` / `drawing` / `section` / `columns` blocks, the de-duped
|
|
# top-level `tables` dict with `unit` split out of each column label, and
|
|
# column/field `choices` also filed under their own key in the top-level
|
|
# `choices` dict (pa-web-spec sections 2 and 4). `_text_type`, `_split_unit`,
|
|
# `_json_safe` and the `_register_table` shape are copied from that module
|
|
# (pa_pagespec.py:120-179, 248-272) -- it is NOT imported here because it
|
|
# pulls the panel modules in.
|
|
|
|
_PAGE_TITLES = (
|
|
('extents', 'Extents & Supports'),
|
|
('materials', 'Materials & Criteria'),
|
|
('loads', 'Loads & Combinations'),
|
|
('reinforcement', 'Reinforcement'),
|
|
('handling', 'Handling'),
|
|
)
|
|
|
|
# Enumeration values a page field or table column offers, kept beside the
|
|
# fields that use them. The allowed strings and the model.py line each cites
|
|
# are the engine's own -- take them from `beamcalc/src/beamcalc/model.py`,
|
|
# never copy a second list that could drift.
|
|
|
|
# `extents.section.type`: the kinds `_section` recognises (model.py:557-573).
|
|
_SECTION_TYPE_CHOICES = ('rectangular', 'L', 'inverted_t')
|
|
# Support kinds: `Support.kind`, defaulted to 'pin' and never validated
|
|
# (model.py:675-678) -- a pin and a roller read identically today.
|
|
_SUPPORT_TYPE_CHOICES = ('pin', 'roller')
|
|
# Which end of the beam a position is measured from (model.py:512-517).
|
|
_FROM_CHOICES = ('start', 'end')
|
|
# A bar row's end condition (model.py:354-355).
|
|
_END_COND_CHOICES = ('straight', 'hook')
|
|
# What a transverse zone is counted toward (model.py:429).
|
|
_TRANSVERSE_USE_CHOICES = ('Av', 'Av+Vs')
|
|
# Deflection self-weight modulus (Settings.ec_for_sw_defl, model.py:144).
|
|
_EC_FOR_SW_CHOICES = ('Ec', 'Eci')
|
|
# Shear design / minimum-shear methods (model.py:150-151).
|
|
_DV_METHOD_CHOICES = ('cg_tension', 'simplified')
|
|
_MIN_METHOD_CHOICES = ('beam_9.6.3', 'AASHTO')
|
|
# The three concrete-type words the engine reads (model.py:586).
|
|
_CONCRETE_TYPE_CHOICES = ('normalweight', 'lightweight')
|
|
# `handling.stripping` stage words (model.py:442-444, Stripping dataclass).
|
|
_FACE_DOWN_CHOICES = ('bottom', 'top')
|
|
_STRENGTH_CHOICES = ('initial', 'final')
|
|
_LIMITS_CHOICES = ('transfer', 'service')
|
|
# The ledger side of an L-beam (model.py:562-567).
|
|
_SIDE_CHOICES = ('left', 'right')
|
|
# Boolean job keys are `'true'`/`'false'` strings in values (spec section 3).
|
|
_BOOL_CHOICES = ('true', 'false')
|
|
|
|
|
|
def _text_type():
|
|
"""`(str,)` on Python 3, `(str, unicode)` on Python 2 -- copied from
|
|
`vibe/pa_pagespec.py` (pa_pagespec.py:120-126)."""
|
|
try:
|
|
return (str, unicode) # noqa: F821 -- Python 2 only
|
|
except NameError:
|
|
return (str,)
|
|
|
|
|
|
_TEXT_TYPES = _text_type()
|
|
|
|
|
|
def _json_safe(obj):
|
|
"""`obj`, recursively coerced into something `json.dumps` accepts and a
|
|
JS reader gets back exactly what it expects: no tuples (-> lists), no
|
|
sets (-> sorted lists), no `NaN`/`Infinity` floats (-> `None`), no
|
|
non-string dict keys (-> `str(key)`). Never raises. Copied from
|
|
`vibe/pa_pagespec.py` (pa_pagespec.py:130-161)."""
|
|
if obj is None or isinstance(obj, bool):
|
|
return obj
|
|
if isinstance(obj, _TEXT_TYPES):
|
|
return obj
|
|
if isinstance(obj, float):
|
|
try:
|
|
if obj != obj or obj in (float('inf'), float('-inf')):
|
|
return None
|
|
except Exception:
|
|
return None
|
|
return obj
|
|
if isinstance(obj, int):
|
|
return obj
|
|
if isinstance(obj, dict):
|
|
out = {}
|
|
for k, v in obj.items():
|
|
if not isinstance(k, _TEXT_TYPES):
|
|
k = str(k)
|
|
out[k] = _json_safe(v)
|
|
return out
|
|
if isinstance(obj, (list, tuple, set, frozenset)):
|
|
return [_json_safe(v) for v in obj]
|
|
return obj
|
|
|
|
|
|
def _split_unit(label):
|
|
"""`('Elevation', 'ft')` from `'Elevation (ft)'`; `(label, None)` when
|
|
there is no trailing parenthetical. A display convenience only -- powers
|
|
`tables[...]['columns'][...]['unit']` -- and the label text is never
|
|
changed anywhere else. Copied from `vibe/pa_pagespec.py`
|
|
(pa_pagespec.py:164-179)."""
|
|
if not label or not isinstance(label, _TEXT_TYPES):
|
|
return label, None
|
|
text = label.strip()
|
|
if text.endswith(')') and '(' in text:
|
|
i = text.rfind('(')
|
|
unit = text[i + 1:-1].strip()
|
|
base = text[:i].strip()
|
|
if unit and base:
|
|
return base, unit
|
|
return label, None
|
|
|
|
|
|
def _field(key, label, required=False, hint=None, choices=None,
|
|
editor='text', symbol=None):
|
|
"""One `field` block. `label` uses the `"Label (unit)"` convention
|
|
(`pa-web-spec.md` section 4) so a trailing unit splits out for the
|
|
`tables` convenience field and for the front end's unit detection; a
|
|
unit that must not ride in the label (a plain `in` field whose label is
|
|
already prose) goes in `symbol` instead -- `app.js`'s `detectUnit`
|
|
reads both the `_ft`/`_in` key suffix and `symbol`, and binds a length
|
|
editor either way. `choices` makes this a dropdown. A numeric scalar
|
|
(a `SCALAR_KEYS` float/int) defaults to a `num` editor; the two
|
|
deflection ratios and everything else stay free `text` (a ratio is
|
|
`"L/240"` or `"240"`, both of which `model.py:600-605` reads)."""
|
|
if editor == 'text' and not choices:
|
|
typ = _SCALAR_TYPES.get(key)
|
|
if typ in (_FLOAT, _INT):
|
|
editor = 'num'
|
|
return {'kind': 'field', 'key': key, 'label': label, 'symbol': symbol,
|
|
'hint': hint, 'required': required, 'editor': editor,
|
|
'choices': list(choices) if choices else None}
|
|
|
|
|
|
def _bool_field(key, label, required=False, hint=None):
|
|
"""A boolean job key, edited as a choice of `'true'`/`'false'` strings
|
|
(values are strings; spec section 3)."""
|
|
return _field(key, label, required=required, hint=hint, editor='choice',
|
|
choices=_BOOL_CHOICES)
|
|
|
|
|
|
def _heading(text, level):
|
|
return {'kind': 'heading', 'text': text, 'level': level}
|
|
|
|
|
|
def _columns(columns):
|
|
return {'kind': 'columns', 'columns': columns}
|
|
|
|
|
|
def _section(title, blocks):
|
|
"""A collapsible group (`pa-web-spec.md` section 2)."""
|
|
return {'kind': 'section', 'title': title, 'collapsed': False,
|
|
'blocks': blocks}
|
|
|
|
|
|
def _editable_table(table, columns, add_label):
|
|
"""One row table, per `pa-web-spec.md` section 2's `editable_table`: the
|
|
block carries no rows (rows live in values under `<table>.<n>.<col>`),
|
|
and the same shape is mirrored, de-duped, in the top-level `tables`
|
|
dict by the build walk."""
|
|
return {'kind': 'editable_table', 'table': table, 'columns': columns,
|
|
'min_rows': 0, 'add_label': add_label}
|
|
|
|
|
|
def _col(col, label, editor='num', choices=None):
|
|
"""One `editable_table` column -- `'Label (unit)'` convention again, so
|
|
the top-level `tables.<name>.columns[].unit` splits out."""
|
|
return {'col': col, 'label': label, 'editor': editor,
|
|
'choices': list(choices) if choices else None}
|
|
|
|
|
|
# ------------------------------------------------------------- table columns
|
|
#
|
|
# Every row table's columns, derived from the TABLES metadata above (same
|
|
# order, same keys) with the display text and unit-per-column vocabulary the
|
|
# spec's section 3/4 tables list. These are what both the page blocks and
|
|
# the de-duped top-level `tables` dict are built from, so one edit here
|
|
# reaches every page that shows the table.
|
|
|
|
_CASE_PRETTY = {
|
|
'deadnc': 'Dead (no construction)', 'topping': 'Topping',
|
|
'deadc': 'Dead (construction)', 'live': 'Live',
|
|
'sus_live': 'Sustained live', 'roof': 'Roof', 'pressure': 'Pressure',
|
|
'suction': 'Suction', 'seismic': 'Seismic',
|
|
}
|
|
|
|
_COLUMN_LABELS = {
|
|
'id': 'ID', 'x_in': 'Position', 'x0_in': 'Start position',
|
|
'x1_in': 'End position', 'ecc_in': 'Eccentricity',
|
|
'ecc0_in': 'Eccentricity at start', 'ecc1_in': 'Eccentricity at end',
|
|
'size': 'Size', 'rows': 'Rows', 'cols': 'Columns',
|
|
'left_in': 'Left cover', 'right_in': 'Right cover',
|
|
'top_in': 'Top cover', 'bottom_in': 'Bottom cover',
|
|
'start_in': 'Start distance', 'end_in': 'End distance',
|
|
'area_in2': 'Area', 'ids': 'Strand ids', 'pull_pct': 'Pull',
|
|
'first_row_in': 'First row height', 'spacing_in': 'Spacing',
|
|
'force_k': 'Force', 'legs': 'Legs', 'first_in': 'First bar at',
|
|
'length_in': 'Zone length', 'qty': 'Quantity',
|
|
'symmetric': 'Symmetric', 'label': 'Label', 'D': 'D', 'L': 'L',
|
|
'R': 'R', 'W': 'W', 'E': 'E',
|
|
}
|
|
|
|
|
|
def _column_label(col):
|
|
"""The bare (unit-less) display label for a table column key."""
|
|
if col in _CASE_PRETTY:
|
|
return _CASE_PRETTY[col]
|
|
for suffix in ('_start', '_end'):
|
|
if col.endswith(suffix):
|
|
stem = col[:-len(suffix)]
|
|
if stem in _CASE_PRETTY:
|
|
return '{0} at {1}'.format(_CASE_PRETTY[stem],
|
|
'start' if suffix == '_start'
|
|
else 'end')
|
|
break
|
|
return _COLUMN_LABELS.get(col, col.replace('_', ' ').title())
|
|
|
|
|
|
def _column_unit(col, table_name=None):
|
|
"""The unit for a table column, or None for a unit-less one. The
|
|
per-case columns carry their own spec-section-3 unit: a bare `<case>` on
|
|
`point_loads` is a kip, on `moments` a kip-ft, and a `<case>_start` /
|
|
`<case>_end` pair half is a klf (ba-web-spec.md section 3)."""
|
|
if col.endswith('_start') or col.endswith('_end'):
|
|
return 'klf'
|
|
if table_name == 'moments' and col in BM.CASES:
|
|
return 'kip-ft'
|
|
if col in BM.CASES:
|
|
return 'kips'
|
|
return {
|
|
'x_in': 'in', 'x0_in': 'in', 'x1_in': 'in', 'ecc_in': 'in',
|
|
'ecc0_in': 'in', 'ecc1_in': 'in', 'left_in': 'in', 'right_in': 'in',
|
|
'top_in': 'in', 'bottom_in': 'in', 'start_in': 'in', 'end_in': 'in',
|
|
'first_row_in': 'in', 'spacing_in': 'in', 'first_in': 'in',
|
|
'length_in': 'in', 'area_in2': 'in2', 'pull_pct': '% of fpu',
|
|
'force_k': 'k',
|
|
}.get(col)
|
|
|
|
|
|
def _column_label_with_unit(col, table_name=None):
|
|
unit = _column_unit(col, table_name)
|
|
if unit is None:
|
|
return _column_label(col)
|
|
return '{0} ({1})'.format(_column_label(col), unit)
|
|
|
|
|
|
def _table_columns(table_name):
|
|
"""The full column list for one row table, in `TABLES[table_name]`'s
|
|
own metadata order -- the position columns, the enumeration columns
|
|
(with their choices, each citing the model.py line that defines the
|
|
allowed strings) and the per-case columns exactly as the metadata's
|
|
`_case_columns()` / `_line_case_columns()` assemble them. One builder
|
|
feeds every page's `editable_table` block and (through `_register_table`)
|
|
the de-duped top-level `tables` dict, so the two can never disagree."""
|
|
meta = TABLES[table_name]
|
|
out = []
|
|
for col, typ, _default in meta['columns']:
|
|
if col == 'include':
|
|
# A 'true'/'false' choice, NOT app.js's 'check' editor: a check
|
|
# stores 'yes'/'', but this spec's values are the job's JSON
|
|
# bool spelling ('true'/'false' -- spec section 3) and
|
|
# `job_from_values`'s `_parse_bool` accepts exactly those, so a
|
|
# checkbox would break Run the first time it was clicked.
|
|
out.append(_col(col, 'Include', editor='choice',
|
|
choices=_BOOL_CHOICES))
|
|
continue
|
|
if col in ('from', 'x0_from', 'x1_from'):
|
|
# Which end of the beam the position is measured from
|
|
# (model.py:512-517); the line-load ones get labels naming
|
|
# their own position column.
|
|
label = {'x0_from': 'Start from', 'x1_from': 'End from'}.get(
|
|
col, 'From')
|
|
out.append(_col(col, label, editor='choice',
|
|
choices=_FROM_CHOICES))
|
|
continue
|
|
if col in ('start_cond', 'end_cond'):
|
|
# model.py:354-355 -- BarRow's straight-or-hook end conditions.
|
|
out.append(_col(col, 'End condition', editor='choice',
|
|
choices=_END_COND_CHOICES))
|
|
continue
|
|
if col == 'use':
|
|
# model.py:429 -- TransverseZone.use.
|
|
out.append(_col(col, 'Use', editor='choice',
|
|
choices=_TRANSVERSE_USE_CHOICES))
|
|
continue
|
|
if col == 'symmetric':
|
|
# A boolean cell -- edited as a 'true'/'false' choice (a check
|
|
# would store 'yes', which the engine's bool() would misread).
|
|
out.append(_col(col, 'Symmetric', editor='choice',
|
|
choices=_BOOL_CHOICES))
|
|
continue
|
|
label = _column_label_with_unit(col, table_name)
|
|
out.append(_col(col, label,
|
|
editor='num' if typ in (_FLOAT, _INT) else 'text'))
|
|
return out
|
|
|
|
|
|
# -------------------------------------------------------------- the drawing
|
|
#
|
|
# `kind: 'drawing'` blocks per ba-web-spec.md section 2's drawings column,
|
|
# with `depends_on` (the values keys that trigger a redraw -- app.js's
|
|
# `redrawDependents` treats each entry as an exact key or a row-table
|
|
# prefix) exactly as section 4 lists them.
|
|
|
|
def _drawing(page_id, source, caption, depends_on, aspect):
|
|
return {'kind': 'drawing', 'source': source, 'caption': caption,
|
|
'full_width': True, 'aspect': aspect,
|
|
'depends_on': list(depends_on),
|
|
'drawing_key': '{0}.{1}'.format(page_id, source)}
|
|
|
|
|
|
_EXTENTS_ELEVATION_DEPS = [
|
|
'extents.length_ft',
|
|
'extents.section.width_in', 'extents.section.height_in',
|
|
'extents.section.type', 'extents.section.stem_width_in',
|
|
'extents.section.stem_height_in', 'extents.section.flange_width_in',
|
|
'extents.section.flange_thickness_in', 'extents.section.side',
|
|
'supports.start.from_start_in', 'supports.start.length_in',
|
|
'supports.start.type', 'supports.start.width_in',
|
|
'supports.end.from_end_in', 'supports.end.length_in',
|
|
'supports.end.type', 'supports.end.width_in',
|
|
]
|
|
|
|
_LOADS_ELEVATION_DEPS = _EXTENTS_ELEVATION_DEPS + [
|
|
'point_loads', 'line_loads', 'moments',
|
|
]
|
|
|
|
_REINFORCEMENT_SECTION_DEPS = [
|
|
'extents.section.width_in', 'extents.section.height_in',
|
|
'extents.section.type', 'extents.section.stem_width_in',
|
|
'extents.section.stem_height_in', 'extents.section.flange_width_in',
|
|
'extents.section.flange_thickness_in', 'extents.section.side',
|
|
'bars', 'strands',
|
|
]
|
|
|
|
_REINFORCEMENT_ELEVATION_DEPS = [
|
|
'extents.length_ft', 'extents.section.height_in',
|
|
'bars', 'transverse',
|
|
]
|
|
|
|
|
|
def _drawings(page_id):
|
|
"""The `drawing` blocks one page carries, in spec section 2's drawings
|
|
column. The `geometry` dict entries they key are filled by the build
|
|
walk from `geometry(job_from_values(values))` -- {} until the geometry
|
|
task lands."""
|
|
if page_id == 'extents':
|
|
return [_drawing('extents', 'elevation',
|
|
'Beam elevation -- length, supports and bearing '
|
|
'pads',
|
|
_EXTENTS_ELEVATION_DEPS, 0.2)]
|
|
if page_id == 'loads':
|
|
return [_drawing('loads', 'elevation',
|
|
'Beam elevation with the applied loads',
|
|
_LOADS_ELEVATION_DEPS, 0.3)]
|
|
if page_id == 'reinforcement':
|
|
return [_drawing('reinforcement', 'section',
|
|
'Reinforcement cross-section',
|
|
_REINFORCEMENT_SECTION_DEPS, 0.28),
|
|
_drawing('reinforcement', 'elevation',
|
|
'Reinforcement elevation -- longitudinal bars and '
|
|
'stirrup zones',
|
|
_REINFORCEMENT_ELEVATION_DEPS, 0.24)]
|
|
return []
|
|
|
|
|
|
# ------------------------------------------------------------------ extents
|
|
|
|
def _stem_flange_fields(values):
|
|
"""The stem/flange dimension `field` blocks -- rendered for EVERY
|
|
section type (`ba-web-spec.md` section 3: the fields "render on every
|
|
type but are `required` only when the type needs them") but marked
|
|
`required` only for the type that reads them: L needs the four plus the
|
|
side (model.py:562-567), inverted T needs the four (model.py:568-572),
|
|
a rectangular beam needs none (model.py:559-561) and is never blocked
|
|
on fields the engine ignores."""
|
|
kind = (values.get('extents.section.type') or 'rectangular')
|
|
kind = kind.strip().lower().replace(' ', '_')
|
|
is_l = kind in _L_KINDS or kind in _IT_KINDS
|
|
fields = [
|
|
_field('extents.section.stem_width_in', 'Stem width (in)',
|
|
required=is_l),
|
|
_field('extents.section.stem_height_in', 'Stem height (in)',
|
|
required=is_l),
|
|
_field('extents.section.flange_width_in', 'Flange width (in)',
|
|
required=is_l),
|
|
_field('extents.section.flange_thickness_in',
|
|
'Flange thickness (in)', required=is_l),
|
|
]
|
|
# The ledger side is an L-beam concept only (model.py:562-567); render
|
|
# it on every type, required just for the L.
|
|
fields.append(_field('extents.section.side', 'Flange side',
|
|
required=kind in _L_KINDS, editor='choice',
|
|
choices=_SIDE_CHOICES))
|
|
return fields
|
|
|
|
|
|
def page_extents(values, reads):
|
|
"""Page 1 -- Extents & Supports (ba-web-spec.md section 2). Edits
|
|
`mark`, `description`, `extents.*`, `supports.*` and `bearing.*`; shows
|
|
the `extents.elevation` drawing."""
|
|
values = values if isinstance(values, dict) else {}
|
|
reads = reads if isinstance(reads, dict) else {}
|
|
blocks = [_heading('Extents & Supports', 1)]
|
|
|
|
mark = (reads.get('mark') or '').strip()
|
|
if not mark:
|
|
mark = (values.get('mark') or '').strip()
|
|
desc = (reads.get('description') or '').strip()
|
|
if not desc:
|
|
desc = (values.get('description') or '').strip()
|
|
if mark or desc:
|
|
rows = []
|
|
if mark:
|
|
rows.append(('Mark', mark))
|
|
if desc:
|
|
rows.append(('Description', desc))
|
|
blocks.append({'kind': 'kv', 'rows': rows})
|
|
|
|
blocks.append(_heading('Beam', 2))
|
|
blocks.append(_columns([
|
|
[_heading('Length', 3),
|
|
_field('extents.length_ft', 'Length (ft)', required=True,
|
|
hint='The overall beam length, end to end (the engine '
|
|
'requires extents.length_ft or length_in, model.py:'
|
|
'662-665).')],
|
|
[_heading('Section', 3),
|
|
_field('extents.section.type', 'Section type', required=True,
|
|
editor='choice', choices=_SECTION_TYPE_CHOICES),
|
|
_field('extents.section.width_in', 'Overall width (in)',
|
|
required=True),
|
|
_field('extents.section.height_in', 'Overall height (in)',
|
|
required=True)] +
|
|
_stem_flange_fields(values),
|
|
]))
|
|
|
|
def _support_fields(side):
|
|
"""Start support measured from the beam start; end support from the
|
|
beam end -- the two offsets are `from_start_in` / `from_end_in` on
|
|
their own job paths (model.py:670-679). The `width_in` keys are not
|
|
SCALAR_KEYS (their engine default is the section width), but they
|
|
are real values keys the page can carry and the drawing depends
|
|
on."""
|
|
which = 'Start' if side == 'start' else 'End'
|
|
from_label = 'from beam start' if side == 'start' \
|
|
else 'from beam end'
|
|
return [_heading(which + ' support', 3),
|
|
_field('supports.{0}.type'.format(side),
|
|
'Type', required=True, editor='choice',
|
|
choices=_SUPPORT_TYPE_CHOICES),
|
|
_field('supports.{0}.from_{1}_in'.format(
|
|
side, 'start' if side == 'start' else 'end'),
|
|
'Bearing centreline {0} (in)'.format(from_label)),
|
|
_field('supports.{0}.length_in'.format(side),
|
|
'Bearing length (in)'),
|
|
_field('supports.{0}.width_in'.format(side),
|
|
'Bearing width (in)')]
|
|
|
|
blocks.append(_heading('Supports', 2))
|
|
blocks.append(_columns([_support_fields('start'),
|
|
_support_fields('end')]))
|
|
|
|
blocks.append(_heading('Bearing', 2))
|
|
blocks.append(_columns([
|
|
[_heading('Bearing check', 3),
|
|
_bool_field('bearing.analyse', 'Run a bearing check?'),
|
|
_field('bearing.mu', 'Friction coefficient (mu)'),
|
|
_field('bearing.nu_ratio', 'nu ratio')],
|
|
[_heading('Bearing', 3),
|
|
_field('supports.start.from_start_in', 'Offset from beam start (in)'),
|
|
_field('supports.start.length_in', 'Length (in)')],
|
|
]))
|
|
blocks.append(_drawings('extents')[0])
|
|
return blocks
|
|
|
|
|
|
# ---------------------------------------------------------------- materials
|
|
|
|
def page_materials(values, reads):
|
|
"""Page 2 -- Materials & Criteria (ba-web-spec.md section 2). Edits
|
|
`materials.*` and `settings.*`; carries no drawing."""
|
|
values = values if isinstance(values, dict) else {}
|
|
blocks = [_heading('Materials & Criteria', 1)]
|
|
|
|
blocks.append(_heading('Materials', 2))
|
|
blocks.append(_heading('Concrete', 3))
|
|
blocks.append(_columns([
|
|
[_field('materials.fc_ksi', 'Concrete strength at 28 days (ksi)',
|
|
required=True,
|
|
hint='The design concrete strength. Required -- the engine '
|
|
'raises JobError without it (model.py:581).'),
|
|
_field('materials.fci_ksi', 'Concrete strength at release (ksi)',
|
|
hint='Blank: the engine reads f\'ci as f\'c (model.py:582).'),
|
|
_field('materials.wc_pcf', 'Unit weight (pcf)'),
|
|
_field('materials.type', 'Concrete type', editor='choice',
|
|
choices=_CONCRETE_TYPE_CHOICES)],
|
|
[_field('materials.ec_ksi', 'Elastic modulus (ksi)',
|
|
hint='Blank: the engine computes Ec from ACI 19.2.2.1(a) '
|
|
'(model.py:79-80, 93-94).'),
|
|
_field('materials.eci_ksi', 'Elastic modulus at release (ksi)',
|
|
hint='Blank: the engine computes Eci from ACI 19.2.2.1(a) '
|
|
'(model.py:80, 97-98).'),
|
|
_field('materials.lambda', 'Lambda (lightweight factor)'),
|
|
_field('materials.rebar.fy_ksi', 'Rebar yield (ksi)'),
|
|
_field('materials.rebar.es_ksi', 'Rebar modulus (ksi)'),
|
|
_field('materials.trans.fy_ksi', 'Transverse rebar yield (ksi)'),
|
|
_field('materials.trans.es_ksi', 'Transverse rebar modulus (ksi)')],
|
|
]))
|
|
blocks.append(_heading('Prestressing strand', 3))
|
|
blocks.append(_columns([
|
|
[_field('materials.strand.fpu_ksi', 'Strand ultimate strength (ksi)'),
|
|
_field('materials.strand.ep_ksi', 'Strand modulus (ksi)'),
|
|
_bool_field('materials.strand.low_relaxation',
|
|
'Low-relaxation strand?')],
|
|
]))
|
|
blocks.append({'kind': 'rule'})
|
|
|
|
# The criteria settings, grouped into collapsible sections (pa-web-spec
|
|
# section 2's `section` block).
|
|
defl = [
|
|
_heading('Deflection limits', 2),
|
|
_field('settings.deflection.total', 'Total deflection limit',
|
|
hint='The total-load limit as a ratio of the span -- "L/240" '
|
|
'or a bare denominator "240" (model.py:600-605).'),
|
|
_field('settings.deflection.live', 'Live deflection limit',
|
|
hint='The live-load limit, same spelling as the total '
|
|
'(model.py:600-605).'),
|
|
_field('settings.deflection.ec_for_sw', 'Modulus for self-weight '
|
|
'deflection', editor='choice', choices=_EC_FOR_SW_CHOICES),
|
|
_heading('Self-weight multipliers', 3),
|
|
]
|
|
for stage in ('erection', 'construction', 'end'):
|
|
defl.append(_field(
|
|
'settings.deflection.multipliers.self_weight.{0}'.format(stage),
|
|
'Self weight, {0}'.format(stage)))
|
|
defl.append(_heading('Additional-dead multipliers', 3))
|
|
for stage in ('construction', 'end'):
|
|
defl.append(_field(
|
|
'settings.deflection.multipliers.addl_dead.{0}'.format(stage),
|
|
'Additional dead, {0}'.format(stage)))
|
|
defl.append(_heading('Flexure', 3))
|
|
defl.append(_field('settings.flexure.ignored_distance_in',
|
|
'Ignored end distance (in)'))
|
|
defl.append(_field('settings.flexure.strain_limit', 'Strain limit'))
|
|
blocks.append(_section('Deflection & flexure', defl))
|
|
|
|
limits = [
|
|
_heading('Transfer limits (x f\'ci)', 3),
|
|
_field('settings.transfer_limits.comp_ends',
|
|
'Compression, ends'),
|
|
_field('settings.transfer_limits.comp_other',
|
|
'Compression, other'),
|
|
_field('settings.transfer_limits.tens_ends_sqrt',
|
|
'Tension, ends (x sqrt(f\'ci) psi)'),
|
|
_field('settings.transfer_limits.tens_other_sqrt',
|
|
'Tension, other (x sqrt(f\'ci) psi)'),
|
|
_heading('Service limits (x f\'c)', 3),
|
|
_field('settings.service_limits.comp_sustained',
|
|
'Compression, sustained'),
|
|
_field('settings.service_limits.comp_total', 'Compression, total'),
|
|
_heading('Shear', 3),
|
|
_field('settings.shear.dv_method', 'Shear design method',
|
|
editor='choice', choices=_DV_METHOD_CHOICES),
|
|
_field('settings.shear.min_method', 'Minimum-shear method',
|
|
editor='choice', choices=_MIN_METHOD_CHOICES),
|
|
_heading('Development', 3),
|
|
_field('settings.development.ktr', 'Ktr (transverse index)'),
|
|
_field('settings.development.mild_mult',
|
|
'Mild-rebar development multiplier'),
|
|
]
|
|
blocks.append(_section('Transfer & service limits', limits))
|
|
|
|
phi = [
|
|
_field('settings.phi.tension', 'Tension (phi)'),
|
|
_field('settings.phi.shear', 'Shear (phi)'),
|
|
_field('settings.phi.compression', 'Compression (phi)'),
|
|
_bool_field('settings.section_properties.transform',
|
|
'Transform reinforcement for deflection?'),
|
|
]
|
|
blocks.append(_section('Resistance factors', phi))
|
|
|
|
return blocks
|
|
|
|
|
|
# -------------------------------------------------------------------- loads
|
|
|
|
def page_loads(values, reads):
|
|
"""Page 3 -- Loads & Combinations (ba-web-spec.md section 2). Edits
|
|
`loads.*` and the `point_loads` / `line_loads` / `moments` /
|
|
`combos_strength` / `combos_service` row tables; shows the
|
|
`loads.elevation` drawing."""
|
|
values = values if isinstance(values, dict) else {}
|
|
blocks = [_heading('Loads & Combinations', 1)]
|
|
|
|
blocks.append(_heading('Load options', 2))
|
|
blocks.append(_columns([
|
|
[_field('loads.self_weight_mult', 'Self-weight multiplier')],
|
|
[_bool_field('loads.pattern_live', 'Pattern live loads?')],
|
|
]))
|
|
|
|
blocks.append(_heading('Point loads', 2))
|
|
blocks.append(_editable_table('point_loads',
|
|
_table_columns('point_loads'),
|
|
'Add point load'))
|
|
blocks.append(_heading('Line loads', 2))
|
|
blocks.append(_editable_table('line_loads',
|
|
_table_columns('line_loads'),
|
|
'Add line load'))
|
|
blocks.append(_heading('Moments', 2))
|
|
blocks.append(_editable_table('moments', _table_columns('moments'),
|
|
'Add moment'))
|
|
|
|
blocks.append(_heading('Combinations', 2))
|
|
blocks.append(_columns([
|
|
[_heading('Strength', 3),
|
|
_editable_table('combos_strength',
|
|
_table_columns('combos_strength'),
|
|
'Add strength combination')],
|
|
[_heading('Service', 3),
|
|
_editable_table('combos_service',
|
|
_table_columns('combos_service'),
|
|
'Add service combination')],
|
|
]))
|
|
|
|
blocks.append(_drawings('loads')[0])
|
|
return blocks
|
|
|
|
|
|
# ------------------------------------------------------------- reinforcement
|
|
|
|
def page_reinforcement(values, reads):
|
|
"""Page 4 -- Reinforcement (ba-web-spec.md section 2). Edits the
|
|
`bars` / `strands` / `transverse` row tables; shows the
|
|
`reinforcement.section` and `reinforcement.elevation` drawings."""
|
|
values = values if isinstance(values, dict) else {}
|
|
blocks = [_heading('Reinforcement', 1)]
|
|
|
|
blocks.append(_heading('Bars', 2))
|
|
blocks.append(_editable_table('bars', _table_columns('bars'),
|
|
'Add bar group'))
|
|
blocks.append(_heading('Strands', 2))
|
|
blocks.append(_editable_table('strands', _table_columns('strands'),
|
|
'Add strand group'))
|
|
blocks.append(_heading('Transverse (stirrups)', 2))
|
|
blocks.append(_editable_table('transverse',
|
|
_table_columns('transverse'),
|
|
'Add stirrup zone'))
|
|
blocks += _drawings('reinforcement')
|
|
return blocks
|
|
|
|
|
|
# ----------------------------------------------------------------- handling
|
|
|
|
def page_handling(values, reads):
|
|
"""Page 5 -- Handling (ba-web-spec.md section 2). Edits
|
|
`handling.stripping.*` only: `handling.handling` and
|
|
`handling.erection` exist in a fixture but are read by nothing in the
|
|
engine, and the page does not show inputs the engine ignores
|
|
(model.py:741-742; spec section 2's last note). No drawing."""
|
|
values = values if isinstance(values, dict) else {}
|
|
blocks = [_heading('Handling', 1)]
|
|
|
|
blocks.append(_heading('Stripping', 2))
|
|
blocks.append(_columns([
|
|
[_field('handling.stripping.multiplier', 'Load multiplier',
|
|
hint='The stripping-stage load factor on the self weight '
|
|
'(model.py:441).'),
|
|
_field('handling.stripping.face_down', 'Face down',
|
|
editor='choice', choices=_FACE_DOWN_CHOICES),
|
|
_field('handling.stripping.strength', 'Concrete strength',
|
|
editor='choice', choices=_STRENGTH_CHOICES),
|
|
_field('handling.stripping.limits', 'Limits', editor='choice',
|
|
choices=_LIMITS_CHOICES)],
|
|
[_heading('Lift points', 3),
|
|
_field('handling.stripping.pick_points', 'Pick points'),
|
|
_field('handling.stripping.lift_from_start_in',
|
|
'Lift from start (in)'),
|
|
_field('handling.stripping.lift_from_end_in',
|
|
'Lift from end (in)')],
|
|
]))
|
|
return blocks
|
|
|
|
|
|
# ------------------------------------------------------------------ geometry
|
|
|
|
def _fmt_ft(v):
|
|
"""A position on the beam's length axis, in feet. The fixtures' lengths
|
|
are `17.916667`-style exact thirds and halves; the drawing wants plain
|
|
numbers, so round to the thousandth of a foot (a hair over 1/100\")."""
|
|
return round(float(v) / 12.0, 3)
|
|
|
|
|
|
def _problems_section(beam):
|
|
"""Section-dimension problems shared by every drawing: a nonpositive
|
|
depth or width would make the section unscalable (a fabricated zero or
|
|
negative size must never be drawn). Returns [] for a healthy section."""
|
|
out = []
|
|
if not (beam.section.height_in > 0.0):
|
|
out.append('the section height {0!r} cannot be drawn; enter a '
|
|
'positive height'.format(beam.section.height_in))
|
|
if not (beam.section.width_in > 0.0):
|
|
out.append('the section width {0!r} cannot be drawn; enter a '
|
|
'positive width'.format(beam.section.width_in))
|
|
return out
|
|
|
|
|
|
def _problems_elevation(beam):
|
|
"""Length-axis problems shared by the two elevation drawings. A
|
|
nonpositive length makes every foot position meaningless; a missing or
|
|
crossed pair of supports leaves no span to hang the elevation on. Returns
|
|
[] for a beam that can be drawn."""
|
|
out = []
|
|
if not (beam.length_in > 0.0):
|
|
out.append('the beam length {0!r} cannot be drawn; enter a '
|
|
'positive length'.format(beam.length_in))
|
|
if not (0.0 <= beam.supports[0].x_in <= beam.length_in
|
|
and 0.0 <= beam.supports[1].x_in <= beam.length_in):
|
|
out.append('a support lies outside the beam; enter supports within '
|
|
'the beam length')
|
|
elif beam.supports[1].x_in <= beam.supports[0].x_in:
|
|
out.append('the supports cross or coincide; enter an end support '
|
|
'past the start support')
|
|
return out
|
|
|
|
|
|
def _elevation_bounds(beam):
|
|
"""The shared `length_ft` / `height_in` every elevation drawing carries
|
|
(ba-web-spec.md section 4), and the x-axis scaling both elevations
|
|
anchor to. `length_ft` is rounded to the thousandth of a foot; `x_in`
|
|
positions compare against the unrounded beam, never this display number."""
|
|
return {'length_ft': round(beam.length_ft, 3),
|
|
'height_in': float(beam.section.height_in)}
|
|
|
|
|
|
def _section_entry(beam):
|
|
"""`reinforcement.section` (ba-web-spec.md section 4): the section
|
|
outline plus one dot per bar and strand centre. Bars/strands are placed
|
|
EXACTLY as the engine places them -- `BarRow.bars(section)` (model.py:
|
|
366-380) and `StrandGroup.strands(section)` (model.py:405-416), which
|
|
spread the rows/cols evenly between the group's left/right/top/bottom
|
|
edge distances -- so the drawing and the section properties never
|
|
disagree about where a bar is. Each bar's `dia_in` is the size's
|
|
`BAR_DIA` (model.py:36-38), or its area-derived diameter where the job
|
|
carries a custom area and no table entry (`db`, model.py:363-364)."""
|
|
problems = list(_problems_section(beam))
|
|
sec = beam.section
|
|
entry = {
|
|
'type': sec.kind,
|
|
'width_in': float(sec.width_in),
|
|
'height_in': float(sec.height_in),
|
|
'stem_width_in': sec.stem_width_in,
|
|
'stem_height_in': sec.stem_height_in,
|
|
'flange_width_in': sec.flange_width_in,
|
|
'flange_thickness_in': sec.flange_thickness_in,
|
|
'side': sec.side,
|
|
}
|
|
if problems:
|
|
entry['bars'] = []
|
|
entry['strands'] = []
|
|
entry['problems'] = problems
|
|
return entry
|
|
|
|
bars = []
|
|
for row in beam.bars:
|
|
area = row.bar_area
|
|
db = BM.BAR_DIA.get(row.size, math.sqrt(4.0 * area / math.pi))
|
|
for x_in, y_in, area, _db in row.bars(sec):
|
|
bars.append({'size': row.size, 'x_in': x_in, 'y_in': y_in,
|
|
'dia_in': db})
|
|
strands = []
|
|
for group in beam.strands:
|
|
for x_in, y_in, _area in group.strands(sec):
|
|
strands.append({'size': group.size, 'x_in': x_in, 'y_in': y_in})
|
|
entry['bars'] = bars
|
|
entry['strands'] = strands
|
|
entry['problems'] = problems
|
|
return entry
|
|
|
|
|
|
def _stirrup_zones(beam):
|
|
"""The stirrup regions as `{x0_ft, x1_ft, spacing_in, size, legs}`
|
|
segments, mirroring `beamcalc/shear.py`'s `zone_at` / `av_provided_per_ft`
|
|
(shear.py:47-101) exactly: a zone's `first_in` is measured from the end
|
|
when `from: 'end'` (flipped the way model.py:512-517 does); the direct
|
|
span runs from the first bar back `spacing_in / 2` to the last bar
|
|
forward `spacing_in / 2`, its length taken from `length_in` -- or, when
|
|
the zone carries none, from `qty` (shear.py:56, 84: `length = length_in
|
|
or (qty - 1) * spacing_in`); a `symmetric` zone ALSO gets the mirror
|
|
span `(L - x1, L - x0)` of its own direct span (shear.py:61-63, 90-92).
|
|
A segment that ends up entirely outside the beam is dropped; one whose
|
|
two faces cross is reported and not drawn."""
|
|
zones = []
|
|
L = beam.length_in
|
|
for z in beam.transverse:
|
|
x0 = (L - z.first_in) if z.from_end else z.first_in
|
|
length = z.length_in if z.length_in else (z.qty - 1) * z.spacing_in
|
|
segments = []
|
|
if z.from_end:
|
|
direct = (x0 - length - z.spacing_in / 2.0,
|
|
x0 + z.spacing_in / 2.0)
|
|
else:
|
|
direct = (x0 - z.spacing_in / 2.0,
|
|
x0 + length + z.spacing_in / 2.0)
|
|
segments.append(direct)
|
|
if z.symmetric:
|
|
a, b = direct
|
|
segments.append((L - b, L - a))
|
|
for (a, b) in segments:
|
|
if b <= 0.0 or a >= L:
|
|
continue # a mirrored segment fully off-beam
|
|
a = max(a, 0.0)
|
|
b = min(b, L)
|
|
if b <= a:
|
|
continue # a segment with no on-beam length
|
|
zones.append({'x0_ft': _fmt_ft(a), 'x1_ft': _fmt_ft(b),
|
|
'spacing_in': float(z.spacing_in), 'size': z.size,
|
|
'legs': z.legs})
|
|
return zones
|
|
|
|
|
|
def _placed(beam):
|
|
"""True when the beam is placeable end to end: a positive length and a
|
|
valid, in-order support pair. The lists are only meaningful relative to
|
|
a real beam, so without this every load/bar/zone is left out and the
|
|
problems sentences say why (never a fabricated position)."""
|
|
return beam.length_in > 0.0 and (
|
|
0.0 <= beam.supports[0].x_in <= beam.length_in
|
|
and 0.0 <= beam.supports[1].x_in <= beam.length_in
|
|
and beam.supports[1].x_in > beam.supports[0].x_in)
|
|
|
|
|
|
def _load_maps(p, cases):
|
|
"""A load row's per-case value map with every zero-load case omitted
|
|
(ba-web-spec.md section 4: "Zero loads are omitted from the
|
|
`kips`/`klf`/`kipft` maps"). A case the engine read as absent is left
|
|
out exactly as `_cases` left it (model.py:641-652)."""
|
|
out = {}
|
|
for case in BM.CASES:
|
|
v = cases.get(case)
|
|
if v is not None and (isinstance(v, tuple) and (v[0] or v[1])
|
|
or not isinstance(v, tuple) and v):
|
|
out[case] = v
|
|
return out
|
|
|
|
|
|
def _moments_entry(beam):
|
|
"""`loads.elevation`'s three load lists with the `_load_maps` filtering;
|
|
a row whose position cannot be resolved is absent and named in
|
|
`problems`, never drawn at a guessed x (pa-web-spec.md section 5)."""
|
|
L = beam.length_in
|
|
placed = _placed(beam)
|
|
problems = list(_problems_elevation(beam))
|
|
point, line, moments = [], [], []
|
|
if placed:
|
|
for p in beam.point_loads:
|
|
kips = _load_maps(p, p.cases)
|
|
if not kips:
|
|
continue # all cases zero -- nothing to draw
|
|
if -1e-9 <= p.x_in <= L + 1e-9:
|
|
point.append({'id': p.id, 'x_ft': _fmt_ft(p.x_in),
|
|
'ecc_in': float(p.ecc_in), 'kips': kips})
|
|
else:
|
|
problems.append(
|
|
'point load {0} at {1:g}\" lies outside the beam; it is '
|
|
'not drawn'.format(p.id, p.x_in))
|
|
for l in beam.line_loads:
|
|
klf = _load_maps(l, l.cases)
|
|
if not klf:
|
|
continue
|
|
a, b = min(l.x0_in, l.x1_in), max(l.x0_in, l.x1_in)
|
|
if a < L and b > 0.0:
|
|
a = max(a, 0.0)
|
|
b = min(b, L)
|
|
entry = {'id': l.id, 'x0_ft': _fmt_ft(a), 'x1_ft': _fmt_ft(b),
|
|
'klf': klf}
|
|
if l.ecc0_in or l.ecc1_in:
|
|
entry['ecc0_in'] = float(l.ecc0_in)
|
|
entry['ecc1_in'] = float(l.ecc1_in)
|
|
line.append(entry)
|
|
else:
|
|
problems.append(
|
|
'line load {0} from {1:g}\" to {2:g}\" lies outside the '
|
|
'beam; it is not drawn'.format(l.id, l.x0_in, l.x1_in))
|
|
for m in beam.point_moments:
|
|
kipft = _load_maps(m, m.cases)
|
|
if not kipft:
|
|
continue
|
|
if -1e-9 <= m.x_in <= L + 1e-9:
|
|
moments.append({'id': m.id, 'x_ft': _fmt_ft(m.x_in),
|
|
'kipft': kipft})
|
|
else:
|
|
problems.append(
|
|
'moment {0} at {1:g}\" lies outside the beam; it is not '
|
|
'drawn'.format(m.id, m.x_in))
|
|
entry = _elevation_bounds(beam)
|
|
entry['supports'] = _support_list(beam)
|
|
entry['point'] = point
|
|
entry['line'] = line
|
|
entry['moments'] = moments
|
|
entry['problems'] = problems
|
|
return entry
|
|
|
|
|
|
def _support_list(beam):
|
|
"""Every support as `{x_ft, length_ft, kind}` (ba-web-spec.md section
|
|
4), placed from `Support.x_in` -- the centreline of bearing `from_start`
|
|
inches (model.py:673-678) -- so the drawing sits where the statics sit.
|
|
An out-of-beam support is still listed, at its real centreline: it is a
|
|
placement the engine accepted, and hiding it would hide the problem the
|
|
elevation's `problems` sentence is naming."""
|
|
out = []
|
|
for s in beam.supports:
|
|
out.append({'x_ft': _fmt_ft(s.x_in),
|
|
'length_ft': round(float(s.length_in) / 12.0, 3),
|
|
'kind': s.kind})
|
|
return out
|
|
|
|
|
|
def geometry(job):
|
|
"""`{'<page_id>.<source>': {...}}` for every drawing, real units only,
|
|
keyed and shaped per ba-web-spec.md section 4 (`extents.elevation`,
|
|
`loads.elevation`, `reinforcement.section`, `reinforcement.elevation`),
|
|
every entry carrying `problems` (pa-web-spec section 5's rule).
|
|
|
|
Built off `beamcalc.model.from_job(job)` (spec section 4's "the same
|
|
function answers POST /api/geometry after an edit"), so every position
|
|
comes from the code the engine runs -- never from re-parsing the job
|
|
JSON. If `from_job` raises, all four entries come back with empty lists
|
|
and the exception text in each `problems`; otherwise each entry carries
|
|
the entries for the drawing it names.
|
|
"""
|
|
try:
|
|
beam = BM.from_job(job)
|
|
except Exception as exc:
|
|
blank = {'problems': ['from_job: {0!r}'.format(exc)]}
|
|
elevation = dict(blank)
|
|
return {'extents.elevation': dict(blank),
|
|
'loads.elevation': dict(blank),
|
|
'reinforcement.section': dict(blank),
|
|
'reinforcement.elevation': elevation}
|
|
|
|
problems = _problems_elevation(beam)
|
|
|
|
extents = _elevation_bounds(beam)
|
|
extents['supports'] = _support_list(beam)
|
|
extents['problems'] = list(problems)
|
|
|
|
entry = _moments_entry(beam)
|
|
|
|
section = _section_entry(beam)
|
|
|
|
reinf_elev = _elevation_bounds(beam)
|
|
reinf_elev['bars'] = []
|
|
reinf_elev['transverse'] = []
|
|
if _placed(beam):
|
|
for row in beam.bars:
|
|
x0 = max(0.0, row.start_in)
|
|
x1 = min(beam.length_in, beam.length_in - row.end_in)
|
|
if x1 > x0:
|
|
reinf_elev['bars'].append({
|
|
'size': row.size, 'x0_ft': _fmt_ft(x0),
|
|
'x1_ft': _fmt_ft(x1),
|
|
'y_in': float(beam.section.height_in - row.top_in),
|
|
'start_cond': row.start_cond, 'end_cond': row.end_cond})
|
|
reinf_elev['transverse'] = _stirrup_zones(beam)
|
|
reinf_elev['problems'] = list(problems)
|
|
|
|
return {'extents.elevation': extents, 'loads.elevation': entry,
|
|
'reinforcement.section': section,
|
|
'reinforcement.elevation': reinf_elev}
|
|
|
|
|
|
# -------------------------------------------------------------------- build
|
|
|
|
def _register_table(block, ctx):
|
|
"""Files one `editable_table` block's shape into the shared `tables`
|
|
dict (`pa-web-spec.md` section 4's `{name: {columns, min_rows,
|
|
add_label}}`) and each column's own `choices`, if any, under
|
|
`'<table>.<col>'` -- a column's choices apply to every row of that
|
|
column, not to one row's not-yet-existing key, so this is the one place
|
|
`choices` is keyed by something other than a `values` key. Same shape
|
|
as `vibe/pa_pagespec.py`'s `_register_table` (pa_pagespec.py:248-272)."""
|
|
name = block.get('table')
|
|
if not name:
|
|
return
|
|
columns = []
|
|
for c in block.get('columns') or []:
|
|
col = c.get('col')
|
|
label, unit = _split_unit(c.get('label'))
|
|
entry = {'key': col, 'label': c.get('label') or col, 'unit': unit,
|
|
'editor': c.get('editor')}
|
|
if c.get('choices'):
|
|
entry['choices'] = list(c['choices'])
|
|
ctx['choices']['{0}.{1}'.format(name, col)] = list(c['choices'])
|
|
columns.append(entry)
|
|
ctx['tables'][name] = {
|
|
'columns': columns, 'min_rows': block.get('min_rows') or 0,
|
|
'add_label': block.get('add_label') or 'Add row',
|
|
}
|
|
|
|
|
|
def _register_drawing(block, ctx):
|
|
"""Files the geometry dict entry one `drawing` block keys. The spec's
|
|
`POST /api/geometry` route rebuilds the whole pagespec from the client's
|
|
current `values` to get this dict -- so it is computed here from
|
|
`job_from_values(ctx['values'])` (the page's own values, not the reads),
|
|
once per build, and every `drawing_key` shares that one snapshot."""
|
|
drawing_key = block.get('drawing_key')
|
|
if not drawing_key:
|
|
return
|
|
if drawing_key not in ctx['geometry']:
|
|
if 'geometry_payload' not in ctx:
|
|
geo = geometry(job_from_values(ctx['values']))
|
|
if not isinstance(geo, dict):
|
|
geo = {'problems': ['geometry() did not return a dict']}
|
|
ctx['geometry_payload'] = geo
|
|
# geometry() returns the WHOLE map keyed "<page>.<source>"; file this
|
|
# drawing's own entry, not the map. Filing the map (the first
|
|
# version did) made every drawing read `geom.length_ft` off a dict
|
|
# of dicts and say "Enter a beam length" with the length filled in --
|
|
# caught by the first headless screenshot (tools/webshot.py), never
|
|
# by a host test.
|
|
entry = ctx['geometry_payload'].get(drawing_key)
|
|
if not isinstance(entry, dict):
|
|
entry = {'problems': ['geometry() has no entry for {0}'.format(drawing_key)]}
|
|
ctx['geometry'][drawing_key] = entry
|
|
|
|
|
|
def _walk(blocks, ctx):
|
|
"""One block list -> the same shape, JSON-safe, with every nested
|
|
container recursed into and every field/table/drawing registered into
|
|
`ctx` -- the beam analogue of `pa_pagespec._walk` (pa_pagespec.py:324-
|
|
376), without that module's WPF-era editor upgrades (this builder
|
|
authors `editor: 'choice'` directly). Never raises: an exception from a
|
|
nested block degrades to a `sentence` naming the failure."""
|
|
out = []
|
|
for block in blocks:
|
|
if not isinstance(block, dict):
|
|
out.append(block)
|
|
continue
|
|
try:
|
|
kind = block.get('kind')
|
|
if kind == 'columns':
|
|
block = dict(block)
|
|
block['columns'] = [_walk(col, ctx)
|
|
for col in (block.get('columns') or [])]
|
|
elif kind == 'section':
|
|
block = dict(block)
|
|
block['blocks'] = _walk(block.get('blocks') or [], ctx)
|
|
elif kind == 'field':
|
|
block = dict(block)
|
|
key = block.get('key')
|
|
if key:
|
|
ctx['field_keys'].add(key)
|
|
if block.get('choices'):
|
|
ctx['choices'][key] = list(block['choices'])
|
|
elif kind == 'editable_table':
|
|
block = dict(block)
|
|
_register_table(block, ctx)
|
|
elif kind == 'drawing':
|
|
block = dict(block)
|
|
_register_drawing(block, ctx)
|
|
out.append(block)
|
|
except Exception as exc:
|
|
out.append({'kind': 'sentence', 'quiet': False,
|
|
'text': '[block failed to serialise: {0!r}]'
|
|
.format(exc)})
|
|
return out
|
|
|
|
|
|
def _basis_dict(values, reads):
|
|
"""`{key: sentence}` -- `reads['basis']` (ba-web-spec.md section 6: a
|
|
values-keyed map, one sentence per prefilled value) filtered to the
|
|
values keys this spec renders, so the page's provenance icon shows on
|
|
exactly the fields the model supplied. Never raises."""
|
|
basis = {}
|
|
raw = reads.get('basis') if isinstance(reads, dict) else None
|
|
if not isinstance(raw, dict):
|
|
return basis
|
|
for key, sentence in raw.items():
|
|
if not isinstance(key, _TEXT_TYPES):
|
|
continue
|
|
if key in values and isinstance(sentence, _TEXT_TYPES):
|
|
basis[key] = sentence
|
|
return basis
|
|
|
|
|
|
def _defaults_why_dict(values, defaults_why):
|
|
"""`{key: why}` -- `values_from_job`'s own `defaults_why` (a sentence
|
|
per key it filled from a model.py default, or left blank with a reason
|
|
why), filtered to the keys this spec renders. Never raises."""
|
|
if not isinstance(defaults_why, dict):
|
|
return {}
|
|
out = {}
|
|
for key, why in defaults_why.items():
|
|
if key in values and isinstance(why, _TEXT_TYPES):
|
|
out[key] = why
|
|
return out
|
|
|
|
|
|
def build_pagespec(values=None, reads=None):
|
|
u"""The whole Beam Analysis input page as one JSON-serialisable `dict`.
|
|
|
|
`values` is the page's flat `{key: string}` store (`values_from_job`'s
|
|
output -- keys are job paths, spec section 3); pass it when the caller
|
|
already built one (the server's `GET /api/spec` always will). Pass
|
|
`None` to have this module build it itself from `reads` -- the
|
|
gatherer's PARTIAL job (spec section 6: only what the model could
|
|
supply, plus `basis`) -- via `values_from_job(reads or {})`, so a page
|
|
opened with no model at all still gets a complete, defaulted form. The
|
|
result is a copy; the caller's `values` dict is never mutated.
|
|
|
|
Returns:
|
|
|
|
{'schema': 1, 'title': 'Beam Analysis', 'mark': str,
|
|
'pages': [{'id', 'title', 'blocks': [...], 'problems': [...]}, ...],
|
|
'values': {key: str, ...}, # every table row key included
|
|
'basis': {key: str, ...},
|
|
'defaults_why': {key: str, ...},
|
|
'choices': {key: [str, ...], ...},
|
|
'tables': {name: {'columns': [...], 'min_rows', 'add_label'}, ...},
|
|
'geometry': {'<page_id>.<source>': {...}, ...},
|
|
'problems': [str, ...]}
|
|
|
|
Top-level shape is pa-web-spec.md section 1's (ba-web-spec.md section 1
|
|
inherits it); pages are the beam spec's own five, in order, per its
|
|
section 2. Never raises: a `page_<id>` that raises becomes a page with
|
|
one `problems` sentence and `blocks: []`, never a missing page.
|
|
"""
|
|
top_problems = []
|
|
reads = reads if isinstance(reads, dict) else {}
|
|
|
|
if values is None:
|
|
try:
|
|
values, defaults_why = values_from_job(reads)
|
|
except Exception as exc:
|
|
top_problems.append(
|
|
'values_from_job raised while building values from reads: '
|
|
'{0!r}; the spec below has no fields pre-filled.'
|
|
.format(exc))
|
|
values, defaults_why = {}, {}
|
|
else:
|
|
values = dict(values) if isinstance(values, dict) else {}
|
|
defaults_why = {}
|
|
|
|
page_fns = {
|
|
'extents': page_extents,
|
|
'materials': page_materials,
|
|
'loads': page_loads,
|
|
'reinforcement': page_reinforcement,
|
|
'handling': page_handling,
|
|
}
|
|
|
|
ctx = {'tables': {}, 'choices': {}, 'geometry': {}, 'field_keys': set(),
|
|
'values': values}
|
|
pages = []
|
|
for page_id, title in _PAGE_TITLES:
|
|
try:
|
|
raw = page_fns[page_id](values, reads)
|
|
if not isinstance(raw, list):
|
|
raise TypeError('page_{0} returned {1}, not a list'.format(
|
|
page_id, type(raw).__name__))
|
|
blocks, problems = raw, []
|
|
except Exception as exc:
|
|
blocks, problems = [], [
|
|
'page_{0} raised: {1!r}'.format(page_id, exc)]
|
|
pages.append({'id': page_id, 'title': title,
|
|
'blocks': _walk(blocks, ctx),
|
|
'problems': problems})
|
|
|
|
mark = (reads.get('mark') or values.get('mark') or '')
|
|
try:
|
|
mark = mark.strip()
|
|
except Exception:
|
|
mark = str(mark) if mark else ''
|
|
|
|
values_out = {}
|
|
for key, v in values.items():
|
|
if isinstance(v, _TEXT_TYPES):
|
|
values_out[key] = v
|
|
else:
|
|
values_out[key] = str(v)
|
|
|
|
return {
|
|
'schema': 1,
|
|
'title': 'Beam Analysis',
|
|
'mark': mark,
|
|
'pages': pages,
|
|
'values': _json_safe(values_out),
|
|
'basis': _json_safe(_basis_dict(values, reads)),
|
|
'defaults_why': _json_safe(_defaults_why_dict(values, defaults_why)),
|
|
'choices': _json_safe(ctx['choices']),
|
|
'tables': _json_safe(ctx['tables']),
|
|
'geometry': _json_safe(ctx['geometry']),
|
|
'problems': top_problems,
|
|
}
|
|
|
|
|
|
def dumps(spec, **kwargs):
|
|
"""`json.dumps(spec)` with `sort_keys=False` (page order is the
|
|
contract -- ba-web-spec.md section 2), `ensure_ascii=False` and
|
|
`default=str` unless the caller overrides any. `ensure_ascii=False`
|
|
for the same reason `pa_pagespec.dumps` documents (pa_pagespec.py:629-
|
|
650): IronPython 2.7's pure-Python ASCII escaper crashes on a real
|
|
non-ASCII character, and real UTF-8 is what every other file this
|
|
codebase writes."""
|
|
kwargs.setdefault('sort_keys', False)
|
|
kwargs.setdefault('ensure_ascii', False)
|
|
kwargs.setdefault('default', str)
|
|
return json.dumps(spec, **kwargs)
|
|
|
|
|
|
def main(argv=None):
|
|
"""Build a Beam Analysis page-spec JSON with NO Revit running:
|
|
|
|
python tools/ba_pagespec.py --out spec.json
|
|
python tools/ba_pagespec.py --reads reads.json --out spec.json
|
|
|
|
`--reads` is a JSON file shaped like the gatherer's reads file
|
|
(ba-web-spec.md section 6: a partial job, plus `basis`). Omitted, or
|
|
pointed at a file that is not there, this runs with `reads = {}` -- an
|
|
empty but still complete and defaulted form (the no-Revit shape the
|
|
HTML page needs to be developable in a browser). `--out` writes the
|
|
spec there (UTF-8); omitted, it prints to stdout.
|
|
"""
|
|
import argparse
|
|
import io
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description='Build the Beam Analysis page-spec JSON.')
|
|
parser.add_argument('--reads', default=None,
|
|
help='JSON reads file (a partial beamcalc job); '
|
|
'omit for an empty, still-complete spec')
|
|
parser.add_argument('--out', default=None,
|
|
help='write the spec here (UTF-8); omit to print '
|
|
'to stdout')
|
|
args = parser.parse_args(argv)
|
|
|
|
reads, reads_err = _load_reads(args.reads)
|
|
if reads_err:
|
|
sys.stderr.write(reads_err + '\n')
|
|
|
|
spec = build_pagespec(values=None, reads=reads)
|
|
blob = dumps(spec)
|
|
|
|
if args.out:
|
|
with io.open(args.out, 'w', encoding='utf-8') as fh:
|
|
fh.write(blob if isinstance(blob, str) else blob.decode('utf-8'))
|
|
sys.stderr.write('wrote {0} ({1} bytes)\n'.format(args.out, len(blob)))
|
|
else:
|
|
print(blob)
|
|
return 0
|
|
|
|
|
|
def _load_reads(path):
|
|
"""`(reads_dict, error_or_None)` -- mirrors `tools/pa_pagespec.py`'s
|
|
loader: a path not given, or not there, is the ordinary "no Revit"
|
|
case (`{}`, no error); a path that IS there and will not parse as a
|
|
JSON object is reported, never silently treated as empty."""
|
|
if not path:
|
|
return {}, None
|
|
if not os.path.isfile(path):
|
|
return {}, "'{0}' does not exist; running with reads = {{}}.".format(
|
|
path)
|
|
try:
|
|
with io.open(path, 'r', encoding='utf-8') as fh:
|
|
data = json.load(fh)
|
|
except Exception as exc:
|
|
return {}, "'{0}' could not be read as JSON ({1!r}); running with " \
|
|
'reads = {{}}.'.format(path, exc)
|
|
if not isinstance(data, dict):
|
|
return {}, "'{0}' is not a JSON object; running with reads = {{}}." \
|
|
.format(path)
|
|
return data, None
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|