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

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())