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.
12 KiB
Beam Analysis web page — the contract
Written 2026-09-05. This is the delta over docs/pa-web-spec.md: everything
that document says about block kinds (§2), the values contract (§3), tables
(§4), the message protocol (§7) and provenance (§8) applies here unchanged and
is not restated. What differs is the page list, the value keys, the tables, the
geometry shapes, and the file the page reads and writes — the beamcalc job
JSON (beamcalc/src/beamcalc/model.py:from_job), which is also the file the
Revit-side gatherer will produce.
Two sides meet at this contract and are built on different machines:
- Host CPython side (this machine):
tools/ba_web.py(server),tools/ba_pagespec.py(job ⇄ pagespec),tools/ba_web/static/(index + drawings),beamcalc/(the engine). No Revit anywhere on this path. - Revit side (the owner's machine): a gatherer that writes the reads
file (§6) from the model and launches
ba_web.py --reads <file> --open, the same way Panel Analysis is launched.
Where this document and model.py disagree, model.py wins and this file is
wrong — fix the file and say so in the commit.
1. One file, three views
The job is a beamcalc/1 JSON document (model.py:658 requires that
schema). The page never invents a second schema:
| view | what it is | who writes it |
|---|---|---|
| reads (§6) | a partial job: only what the model can supply, plus basis |
the Revit gatherer |
| values | the pagespec's flat {key: string} store, keys = job paths (§3) |
ba_pagespec.values_from_job from reads + defaults; then the user |
| job | the full job the engine runs | ba_pagespec.job_from_values on Run |
Round trip is the invariant and the test: job_from_values(values_from_job(J))
reproduces J for both fixtures in beamcalc/fixtures/, key for key, after
from_job normalisation (a value the job left to a default may come back
explicit — that is the only permitted difference).
2. Pages
Five pages, fixed order and ids. Each page's blocks are built by one function
page_<id>(values, reads) -> [blocks] in tools/ba_pagespec.py; a page that
raises produces a page with a single problems sentence, never a blank tab
(pa-web-spec.md:50 rule).
| # | id | title | job sections it edits | drawings |
|---|---|---|---|---|
| 1 | extents |
Extents & Supports | mark, description, extents.*, supports.*, bearing.* |
extents.elevation |
| 2 | materials |
Materials & Criteria | materials.*, settings.* |
— |
| 3 | loads |
Loads & Combinations | loads.*, combos.* |
loads.elevation |
| 4 | reinforcement |
Reinforcement | reinforcement.* |
reinforcement.section, reinforcement.elevation |
| 5 | handling |
Handling | handling.stripping.* |
— |
handling.handling and handling.erection exist in one fixture and are read
by nothing (model.py:741-742); the page does not show them until the
engine does. Showing an input the engine ignores is the silent-success failure
PROJECT.md §3.1 forbids.
3. Values keys
A scalar's key is its dotted path in the job. extents.length_ft,
extents.section.width_in, materials.rebar.fy_ksi,
settings.deflection.total, supports.start.from_start_in,
handling.stripping.multiplier, bearing.mu, loads.self_weight_mult,
loads.pattern_live. Every value is a string in values; job_from_values
parses it with the type model.py expects at that path. Booleans are
'true'/'false'. Deflection ratios accept "L/240" or "240" exactly as
model.py:600-605 does.
extents.section.type is a choices field: rectangular, L, inverted_t
(model.py:557-573). The four stem/flange fields and side render on every
type but are required only when the type needs them.
Lists are row tables in the pa-web-spec.md §3 shape
(<table>.<n>.<col>, <table>.<n>.present, ids never reused):
| table | job list | columns (in order) |
|---|---|---|
point_loads |
loads.point[] |
id, x_in, from (choices start/end), ecc_in, one column per case in model.CASES, description |
line_loads |
loads.line[] |
id, x0_in, x0_from, x1_in, x1_from, ecc0_in, ecc1_in, per case two columns <case>_start / <case>_end (klf), description |
moments |
loads.moments[] |
id, x_in, from, one column per case (kip-ft) |
combos_strength |
combos.strength[] |
label, D, L, R, W, E, include |
combos_service |
combos.service[] |
same |
bars |
reinforcement.bars[] |
size, rows, cols, left_in, right_in, top_in, bottom_in, start_in, end_in, start_cond, end_cond, area_in2 |
strands |
reinforcement.strands[] |
ids, size, pull_pct, rows, cols, left_in, right_in, first_row_in, spacing_in, force_k |
transverse |
reinforcement.transverse[] |
size, legs, first_in, from, spacing_in, length_in, qty, symmetric, use, area_in2 |
model.CASES is ('deadnc', 'topping', 'deadc', 'live', 'sus_live', 'roof', 'pressure', 'suction', 'seismic') (model.py:29-30); the pagespec reads it
from the module, never copies it. Column labels carry units in the
"Label (unit)" form so tables.<name>.columns[].unit splits out as in PA.
The two combination tables are editable_tables pre-filled from
model.DEFAULT_STRENGTH / DEFAULT_SERVICE when the job carries none; include
is a checkbox column. They are not PA's lc_table — that block's fixed
code-row/user-row split is the panel tool's vocabulary.
4. Geometry
Keyed "<page_id>.<source>", real units only, every entry carries problems
(pa-web-spec.md §5 rules). Built by ba_pagespec.geometry(job); the same
function answers POST /api/geometry after an edit.
"extents.elevation": {
"length_ft": 17.92,
"height_in": 22.0,
"supports": [ {"x_ft": 0.0, "length_ft": 0.5, "kind": "pin"}, // start
{"x_ft": 17.42, "length_ft": 0.5, "kind": "pin"} ], // end
"problems": []
},
"loads.elevation": { // extents.elevation + the loads on it
"length_ft": 17.92, "height_in": 22.0, "supports": [...],
"point": [ {"id": "P1", "x_ft": 3.0, "ecc_in": 0.0, "kips": {"deadnc": 4.2, "live": 6.0}} ],
"line": [ {"id": "L1", "x0_ft": 0.0, "x1_ft": 17.92, "klf": {"deadnc": [0.9, 0.9]}} ],
"moments": [ {"id": "M1", "x_ft": 0.0, "kipft": {"deadnc": 12.0}} ],
"problems": []
},
"reinforcement.section": {
"type": "rectangular", "width_in": 16.0, "height_in": 22.0,
"stem_width_in": null, "stem_height_in": null, "flange_width_in": null,
"flange_thickness_in": null, "side": null,
"bars": [ {"size": "#8", "x_in": 2.0, "y_in": 2.0, "dia_in": 1.0} ], // y from bottom
"strands": [ {"size": "1/2", "x_in": 3.0, "y_in": 2.0} ],
"problems": []
},
"reinforcement.elevation": {
"length_ft": 17.92, "height_in": 22.0,
"bars": [ {"size": "#8", "x0_ft": 0.0, "x1_ft": 17.92, "y_in": 2.0,
"start_cond": "straight", "end_cond": "hook"} ],
"transverse": [ {"size": "#4", "x0_ft": 0.0, "x1_ft": 4.0, "spacing_in": 6.0, "legs": 2} ],
"problems": []
}
Zero loads are omitted from the kips/klf/kipft maps. A row whose position
cannot be resolved is absent from the drawing and named in problems
(pa-web-spec.md:403 rule) — never drawn at a fabricated x.
5. Server
tools/ba_web.py is tools/pa_web.py with the tool swapped. Same argv
(--reads, --job-root, --port, --open, --title), same first-line
BA_WEB_URL=http://127.0.0.1:<port>/ on stdout, same 127.0.0.1 bind, same
ThreadingHTTPServer. Files live under <job_root>/beamanalysis/:
| path | file |
|---|---|
POST /api/save |
<mark>.values.json |
POST /api/run |
<mark>.job.json (the job actually run), <mark>.ba.json (cli.summary(a)), <mark>.html (report.render(a)) → {"ok": true, "result_url": "/results/<mark>.html"} |
POST /api/run failure |
{"ok": false, "problems": [{"key": <values key or ''>, "text": ...}]}; a model.py ValueError maps to the key whose value caused it where that can be told |
POST /api/geometry |
{"geometry": ba_pagespec.geometry(job_from_values(values))} |
POST /api/log |
appends the client-side error to <mark>.client.log, returns {"ok": true} — the route PA's app.js already calls and PA's server lacks; added to pa_web.py too |
GET /api/spec, GET /results/<file>, POST /api/close |
as PA |
The run calls beamcalc.solve.analyse in-process (the way PA calls
panelanalysis.build_page), never a subprocess: one interpreter, one traceback.
Static files are served from tools/ba_web/static/ first and
tools/pa_web/static/ second. app.js, units.js, willtools.css and
draw.js are not copied — one copy, PA's. tools/ba_web/static/ holds
only index.html and ba_drawings.js.
6. The reads file — the Revit-side half
The gatherer writes a partial job:
{
"schema": "beamcalc/1",
"job_root": "C:\\...\\<job>", // where beamanalysis/ goes; as PA
"mark": "RB1",
"description": "16RB22, level 2",
"extents": { "length_ft": 17.92, "section": {"type": "rectangular", "width_in": 16, "height_in": 22} },
"materials": { "fc_ksi": 6.0, "fci_ksi": 4.0 },
"reinforcement": { "bars": [...], "strands": [...], "transverse": [...] },
"basis": { "extents.length_ft": "Length parameter of the beam instance",
"materials.fc_ksi": "Type parameter f'c" }
}
Rules, all of them pa-web-spec.md §8's:
- Absent means unread. Nothing the model does not carry appears at all —
never
0, nevernull-as-zero.ba_pagespec.values_from_jobfills the rest frommodel.py's own defaults and records each indefaults_why. basiskeys are values keys (§3), one sentence each, so the page can show the provenance icon on exactly the fields the model supplied.- Product codes only (
#8,1/2,RB1); no client, job or plant name in the file (CLAUDE.md§3).job_rootis a path and is never printed by the server. - Anything the gatherer wants to add that
model.py:from_jobdoes not read is a schema change: it lands inmodel.pyfirst, in this document second, and the commit names both.
7. Front end
app.js is PA's, unchanged except one hook: before its own redrawOne
dispatch it consults window.ToolDrawings — { "<source>": fn(inst, state, host) } — and calls the tool's function when the block's source is
registered. draw.js exports its primitives (svgRoot, label, dimH,
dimV, ftIn, inch, el, clear, safeNum, clamp, mergeRanges,
estWidth, _stagger) on window.Draw so ba_drawings.js draws with the
same pen. Both edits are additive; PA's behaviour with no ToolDrawings
defined is byte-for-byte what it was.
ba_drawings.js registers elevation (used by extents.elevation and
loads.elevation) and section; reinforcement.elevation is elevation
with bars and stirrup zones overlaid, selected by inst.page.id the way PA
selects its two sections.
8. Tests
WillTools.extension/tests/test_ba_pagespec.py: round trip (§1) on both
fixtures; every §3 table present with the columns listed; geometry() on both
fixtures has no problems; a job with an L section renders the stem/flange
fields required. tests/test_ba_web.py: start the server on port 0, GET /api/spec, POST /api/save, POST /api/run on the RBA5 fixture writes the
three files and returns a result_url that serves; POST /api/log writes the
log; static fallback serves app.js from PA's directory. No Revit in any of it.