# -*- 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 `..` with `
..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() 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: `_start` and `_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 `_start` / # `_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 `
..` rows with `
..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.`. 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 `
..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 + [''.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_(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 `
..`), 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..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 `` on `point_loads` is a kip, on `moments` a kip-ft, and a `_start` / `_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): """`{'.': {...}}` 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 `'
.'` -- 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 "."; 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': {'.': {...}, ...}, '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_` 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())