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

2863 lines
136 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Panel Analysis -- an Eriksson-Wall-shaped page, fed by a frozen deck.
python tools/panelanalysis.py DECK.job.json -o OUT.html
python tools/panelanalysis.py DECK.job.json -o OUT.html --spec SPEC.yaml
python tools/panelanalysis.py DECK.pa.json -o OUT.html # panelanalysis/1
TWO DECK SHAPES, ONE PAGE LAYOUT. `build_page` dispatches on the deck's
`schema`:
panellift/1 the frozen handling job (see below) -- rendered as the
pre-filled INPUT FORM this tool has always drawn: every
field an <input>, editing recomputes nothing.
panelanalysis/1 the Panel Analysis deck (see `panelanalysis_schema.py`),
organised in Eriksson-named blocks. For this one the page
actually SOLVES the self-weight Handling model -- the
panel as a beam on its `structural_model.supports`,
carrying its own weight -- reusing `panellift`'s engine
end to end (`statics.analyse` -> `_support_rig` +
`solve_static` + `diagram`; `section.NetSection`;
`capacity.capacity_at`). It renders the reactions, the
shear/moment diagrams and a per-station demand-vs-capacity
table, and leaves every In-Place section as an explicit
NOT-YET-BUILT placeholder (Stage 3), never a fabricated
zero. A deck with fewer than two supports is refused
LOUDLY -- `panellift`'s own "a supported stage needs at
least two supports" surfaced as a NOT-JUDGED box, never a
traceback and never a silent OK.
DECK (form path) is one panel's frozen input deck -- a `*.job.json` written by
`liftexport.py` under a sweep's `jobs/` folder (see `tools/deckcheck.py`'s own
docstring for what a job file is and why it is frozen). This tool does not
check the panel; it lays out the SAME information Eriksson Wall's input form
asks an engineer to type, pre-filled from whatever this shop's tools already
know, next to a small slice of the results Eriksson would compute -- section
properties, effective prestress, cracking moment, strand-only phi*Mn -- using
`panellift`'s own engine. Nothing else Eriksson computes (wind, seismic,
P-Delta, slenderness, in-place load cases) is built here; those sections
render as explicit placeholders, honest about being unbuilt, not a fake zero.
THE FORM, NOT THE REPORT. The owner's own words, mid-build (2026-09-01):
"支座位置不管读的到读不到,都给用户手动输入修改的机会。基本上就是照抄
eriksson wall的组织形式和输入形式,只不过预先通过已经知道的信息,来预先
输入内容,节省用户输入的时间。"
(Whether a support location was read off the model or not, give the user
a place to type or correct it by hand. Copy Eriksson Wall's organisation
AND its input form, and pre-fill what is already known to save typing.)
So every parameter on the page is an `<input>`, never a read-only cell --
`finput()` below is the one function that draws one, pre-filled from a real
source (deck / spec / current knowledge / this tool's own computation) or
left empty when nothing supplied it, and beside it a small chip names that
source. Editing a chip's input flips it to "EDITED" with three lines of
vanilla JS (`wtEdited()`, in the page itself) -- and edits recompute NOTHING.
That is not a corner cut for the demo, it is the demo: the analysis pipeline
behind this form is the next phase, and the page says so once, plainly, near
the top, rather than pretending a keystroke changed a number underneath it.
Only the RESULTS sections (section properties, capacity) are not inputs --
they are this tool's own output, from `panellift.section` / `.capacity` /
`.prestress`, exactly as `panellift`'s own handling-check report computes
them. Nothing there is invented for this page; see WHAT IS COMPUTED below.
THREE REAL SOURCES, CHIPPED LIKE PROJECT CONFIG CHIPS ITS LAYERS:
deck the frozen job file itself -- geometry, lifters, strands,
stages, everything `liftexport.py` measured off the model.
spec the design extract YAML (`knowledge/job/.../specs/<design>.
yaml`), auto-matched off the deck's own `source.design` /
`panel.design_number` -- `--spec` overrides the guess.
knowledge the CURRENT merged plant/job/catalogue knowledge, resolved
off-Revit the same way `tools/deckcheck.py` does (see
`WHY NOT `import vibe.knowledge`` there -- the same reason
applies here and this tool reuses that module rather than
re-deriving the merge).
computed this tool's own arithmetic (self weight reconciliation,
transfer length, f_se, section properties, capacity).
absent real, specific, and not fabricated: the field was looked for
and nothing supplied it (e.g. `source.connectors`, which
connector recognition has never been wired to read).
planned named by the owner as a FUTURE input source, not built here:
a `wallanalysis` Project Config module for wind speed, Kzt,
the ASCE edition and a parameter table he will supply later.
WHAT IS COMPUTED (real, narrow, from `panellift`, reused not reinvented):
section properties per station (`panellift.section`), effective prestress /
f_se, cracking moment M_cr, strand-only phi*Mn at L/20 stations plus any
opening-edge discontinuity (`panellift.capacity`), and the self-weight
reconciliation (geometry-derived weight against the model's own stated
weight, `panellift.section.weight_reconciliation`). No load case is built --
there is no real bending moment to check phi*Mn against, so M/phi*Mn and
V/phi*Vn are never printed; showing a ratio against a moment nobody computed
would be exactly the fabricated number this whole codebase exists to refuse.
WHAT IS NOT COMPUTED, on purpose, and says so on the page: wind (C&C /
MWFRS), live, seismic and earth load cases and their ACI 318 load
combinations; P-Delta / second-order effects; slenderness; axial
interaction; horizontal shear / connector demand (also blocked on
`source.connectors` being unread, see the Structural Model section); and
in-place strength/service tables, all of which need the load-case engine
above. The full list is repeated once in the page's own placeholder index.
"""
from __future__ import annotations
import argparse
import datetime
import io
import itertools
import json
import os
import re
import sys
import types
import webbrowser
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
# ---------------------------------------------------------------- wiring up
# `panellift` is plain CPython (dataclasses, typing syntax) and lives at
# panellift/src -- the same PYTHONPATH the project's own tests and CLI use
# (`cd panellift && PYTHONPATH=src python -m pytest`). Adding it to sys.path
# here means this tool can `import panellift` without the caller having to
# set an environment variable first.
PANELLIFT_SRC = os.path.join(REPO, 'panellift', 'src')
if PANELLIFT_SRC not in sys.path:
sys.path.insert(0, PANELLIFT_SRC)
# `tools/deckcheck.py` already solved "read vibe.knowledge off Revit" --
# the `vibe/__init__.py` stub trick, the PyYAML layer reader, the plant/job/
# config merge in `knowledge.load()`'s own order -- and its own docstring is
# explicit that this is not to be re-derived. Reuse it by importing the
# module itself: `tools/` is already on sys.path (this file's own directory,
# which Python puts there for the script being run), so a bare `import
# deckcheck` picks up the sibling file.
if HERE not in sys.path:
sys.path.insert(0, HERE)
import deckcheck # noqa: E402 -- sets up the `vibe` stub as a side effect
import dataclasses # noqa: E402
from panellift import capacity, inplace, prestress, section, statics # noqa: E402
from panellift import cli as pl_cli # noqa: E402
from panellift import report as R # noqa: E402
from panellift.model import Stage # noqa: E402
import panelanalysis_schema as schema # noqa: E402 -- sibling on tools/
try:
import yaml # noqa: E402
except ImportError: # pragma: no cover
sys.stderr.write('PyYAML is required: pip install pyyaml\n')
raise SystemExit(2)
# ------------------------------------------------------------------- sources
# The results page (panelanalysis/1, `build_analysis_page`) is READ-ONLY: the
# owner's ruling is that the input side now lives entirely in the 前端
# (`tools/pa_web/static/index.html`), so this page never draws an
# `<input>`/`<select>` and never wears a DECK/NOT READ/COMPUTED badge -- see
# the module docstring's old "THE FORM, NOT THE REPORT" section, which
# described the PREVIOUS design and is superseded for this schema. Instead of
# ripping out `finput`/`fselect`/`field_row`/`select_row` (still the shape
# `build_page`'s OTHER branch -- the older `panellift/1` pre-filled INPUT FORM
# -- draws, unchanged, and still covered by whatever calls it), each grew a
# `readonly` flag: True renders `rval()`'s quiet "value + muted provenance
# word" instead of an editable field + chip. `PROV_WORD` is deliberately much
# smaller than `CHIPS` -- 'absent' / 'placeholder' / 'planned' carry no word
# at all here, because a missing/not-yet-built field over the readonly path
# already renders as a plain dash (see `rval`), never a labelled empty box.
PROV_WORD = {
'deck': 'from model',
'spec': 'design extract',
'knowledge': 'plant knowledge',
'computed': 'computed',
'entered': 'entered',
}
CHIPS = {
'deck': 'DECK',
'spec': 'SPEC',
'knowledge': 'KNOWLEDGE',
'computed': 'COMPUTED',
'absent': 'NOT READ',
'placeholder': 'NOT COMPUTED',
'planned': 'PLANNED',
}
_uid = itertools.count(1)
def _next_id():
return 'f{0}'.format(next(_uid))
def chip(source, detail=None, chip_id=None):
text = CHIPS.get(source, source.upper())
title = ' title="{0}"'.format(R.esc(detail)) if detail else ''
idattr = ' id="{0}"'.format(chip_id) if chip_id else ''
return '<span{3} class="chip chip-{0}"{1}>{2}</span>'.format(
source, title, text, idattr)
def rval(value, source, detail=None, word=True):
"""The read-only twin of `finput` -- a value, quietly sourced.
A `None` (or empty-string) value is a dash carrying its own reason as a
hover title, the same discipline `_cell` uses for a computed result that
could not be judged -- never an empty box, because there is no field to
fill in any more. A value renders plain, with a small muted provenance
word beside it ONLY where `PROV_WORD` names one for this `source` --
'absent' / 'placeholder' / 'planned' name none, so a field the tool
never read carries no badge at all, just its dash.
`word=False` suppresses that word even where `PROV_WORD` has one -- for
a dense per-row table (strand layout, mild bars, supports/restraints,
wind zones) where every cell in every column shares the SAME source and
repeating "from model" down eight rows and six columns is noise, not
information. Those tables name the source ONCE, in a caption above the
table, instead -- see e.g. `sec_prestress`'s "Strand layout" heading.
"""
if value is None or value == '':
title = ' title="{0}"'.format(R.esc(detail)) if detail else ''
return '<span class="dash"{0}>&mdash;</span>'.format(title)
prov = ''
if word:
w = PROV_WORD.get(source)
if w:
title = ' title="{0}"'.format(R.esc(detail)) if detail else ''
prov = ' <span class="prov"{0}>{1}</span>'.format(title, R.esc(w))
return '<span class="rval">{0}</span>{1}'.format(
R.esc(str(value)), prov)
def finput(value, source, detail=None, width=10, readonly=False, word=True):
"""One editable, pre-filled field -- the page's one atom.
`value` None renders an EMPTY input, not a dash: the owner's ruling is
that a support location gets a place to type it in whether the model
supplied one or not, and that holds for every field on this page, not
support locations alone. The chip beside it still names where a value
WOULD have come from, so an empty box reads as "nothing supplied this"
rather than as a field nobody thought of. The chip carries its OWN id
(`{fid}-chip`) so `wtEdited()` in the page's own script can find and
flip it the moment the field is touched.
`readonly=True` skips all of that and renders `rval()` instead -- the
results page's own mode, see `PROV_WORD`'s and `rval`'s docstrings above
(`word` passes straight through to `rval`).
"""
if readonly:
return rval(value, source, detail, word=word)
fid = _next_id()
val = '' if value is None else str(value)
return (
'<span class="fwrap"><input id="{fid}" class="finput" type="text" '
'size="{w}" value="{val}" oninput="wtEdited(\'{fid}\')">'
'{chip}</span>'.format(
fid=fid, w=width, val=R.esc(val),
chip=chip(source, detail, chip_id=fid + '-chip')))
def fselect(value, options, source, detail=None, readonly=False, word=True):
if readonly:
return rval(value, source, detail, word=word)
fid = _next_id()
opts = ''.join(
'<option value="{0}"{1}>{0}</option>'.format(
R.esc(o), ' selected' if o == value else '')
for o in options)
return (
'<span class="fwrap"><select id="{fid}" class="finput" '
'onchange="wtEdited(\'{fid}\')">{opts}</select>{chip}</span>'
.format(fid=fid, opts=opts,
chip=chip(source, detail, chip_id=fid + '-chip')))
def field_row(label, value, source, detail=None, width=12, readonly=False):
return '<tr><th>{0}</th><td>{1}</td></tr>'.format(
R.esc(label), finput(value, source, detail, width, readonly=readonly))
def select_row(label, value, options, source, detail=None, readonly=False):
return '<tr><th>{0}</th><td>{1}</td></tr>'.format(
R.esc(label), fselect(value, options, source, detail,
readonly=readonly))
def note_row(label, text):
"""A row with no field at all -- prose, not a parameter."""
return '<tr><th>{0}</th><td class="fieldnote">{1}</td></tr>'.format(
R.esc(label), R.notate(text))
def table(rows_html, cls='kv'):
return '<table class="{0}">{1}</table>'.format(cls, ''.join(rows_html))
def fmt(value, digits=2):
return '' if value is None else R.n(value, digits)
def _ft_val(value):
"""A feet LENGTH headed into `finput`: formatted feet-inches, but None
passes straight through so the field still renders EMPTY rather than as
the string '-' (`finput`'s own None contract, see its docstring) --
`R.fmt_ft_in` alone is not safe to use directly on an editable field
because it turns None into '-'; use it directly for a DISPLAY-ONLY
string instead, where a dash is the right way to say "no value".
"""
return None if value is None else R.fmt_ft_in(value)
def _in_val(value):
"""The inches twin of `_ft_val`, for a dimension already spoken of in
inches (thickness, depth, eccentricity, diameter) headed into `finput`.
"""
return None if value is None else R.fmt_in(value)
# ---------------------------------------------------------- knowledge / spec
def resolve_job_root(deck_path):
jobs_dir = os.path.dirname(os.path.abspath(deck_path))
return deckcheck._guess_job_root(jobs_dir)
def resolve_knowledge(job_root):
"""(merged, layers, error) -- error is None on success.
Failure degrades rather than crashing the whole render: a plant/job
layer that will not parse should not stop the deck and spec halves of
the page from showing what they know. `knowledgelint.py` is the tool
that actually enforces every layer parses; this one just does not fall
over if it doesn't.
"""
try:
merged, layers = deckcheck.build_knowledge(job_root)
return merged, layers, None
except Exception as exc: # pragma: no cover
return None, None, str(exc)
def k(merged, dotted, default=None):
if merged is None:
return default
return deckcheck.knowledge.get(merged, dotted, default)
def guess_design(job):
"""The design number a deck names, base form (`WP1.0` -> `WP1`).
Prefers `source.design`, which `liftexport.py` already writes stripped
of any packet suffix -- see `willtools-precast-nomenclature.md`: a
packet suffix or a `*.A` note is not a separate design, and neither is
the trailing `.0` view/sheet index on `panel.design_number`. Falls back
to splitting `panel.design_number` on the first `.` for an older deck
that carries no `source.design`.
"""
src = job.get('source') or {}
design = src.get('design')
if design:
return str(design)
dn = (job.get('panel') or {}).get('design_number')
if dn:
return str(dn).split('.')[0]
return None
def load_spec(job, job_root, override):
"""(spec_dict, spec_path, design, reason_if_none)."""
design = guess_design(job)
if override:
path = override
elif design:
path = os.path.join(job_root, 'specs', '{0}.yaml'.format(design))
else:
return None, None, design, 'this deck names no design number at all'
if not os.path.isfile(path):
return None, None, design, (
'no spec file at {0} -- this page falls back to model-only '
'fields for anything the spec would have supplied'.format(path))
try:
with io.open(path, 'r', encoding='utf-8') as fh:
spec = yaml.safe_load(fh)
except Exception as exc: # pragma: no cover
return None, path, design, 'spec file would not parse: {0}'.format(exc)
return (spec or {}), path, design, None
# --------------------------------------------------------------- panel build
def build_panel(job):
"""A `panellift.model.Panel`, with job-level `source` merged in.
The exact one-liner `panellift.cli.analyse_job` uses -- the exporter
writes provenance (`connectors`, `layup`, the up-end basis) at job level,
not inside `panel`, so it has to be merged on before anything downstream
can read it. Reused rather than re-derived so this page can never drift
from what the real check does with the same file.
"""
panel = pl_cli.read_panel(job['panel'])
panel = dataclasses.replace(
panel, source=dict(job.get('source') or {}, **(panel.source or {})))
return panel
# ------------------------------------------------------------------ figures
def panel_plan_svg(panel):
"""The piece as cast: extents, openings, recesses, every insert located.
A thin composition of `panellift.report.Plot` -- the same canvas
`panel_plan()` there draws with -- rather than a call to that function
itself, because `panel_plan()` wants a full `Analysis` (pick LINES, which
need the rigging solved) and this page deliberately does none of that
solving. Same CSS classes, same idiom, no statics.
"""
half = panel.width_ft / 2.0
p = R.Plot(height=210, pad_t=24, pad_b=42)
p.domain(0, panel.length_ft, -half * 1.18, half * 1.18)
x0, x1 = p.px(0), p.px(panel.length_ft)
yt, yb = p.py(half), p.py(-half)
p.add('<rect class="panel" x="{0:.2f}" y="{1:.2f}" width="{2:.2f}" '
'height="{3:.2f}"/>'.format(x0, yt, x1 - x0, yb - yt))
for b in panel.blockouts:
bx, bw = p.px(b.x0), p.px(b.x1) - p.px(b.x0)
by = p.py(b.y0 + b.width - half)
bh = p.py(b.y0 - half) - by
p.add('<rect class="void" x="{0:.2f}" y="{1:.2f}" width="{2:.2f}" '
'height="{3:.2f}"/>'.format(bx, by, bw, bh))
for rc in panel.recesses:
rx, rw = p.px(rc.x0), p.px(rc.x0 + rc.length) - p.px(rc.x0)
ry = p.py(rc.y0 + rc.width - half)
rh = p.py(rc.y0 - half) - ry
p.add('<rect class="recess" x="{0:.2f}" y="{1:.2f}" width="{2:.2f}" '
'height="{3:.2f}"/>'.format(rx, ry, rw, rh))
for l in panel.lifters:
cls = 'insert-strip' if l.role == 'strip' else 'insert-other'
cx, cy = p.px(l.x), p.py(l.y - half)
p.add('<circle class="{0}" cx="{1:.2f}" cy="{2:.2f}" r="5"/>'
.format(cls, cx, cy))
p.label_px((x0 + x1) / 2.0, yb + 28,
'{0} x {1} x {2}'.format(
R.fmt_ft_in(panel.length_ft), R.fmt_ft_in(panel.width_ft),
R.fmt_in(panel.thickness_in)))
return p.render('Concrete extents, as exported from the model')
# --------------------------------------------------------------- section 5.
def section_properties_rows(panel):
"""Eriksson's own Design-Parameters convention: L/10, 11 stations."""
L = panel.length_ft
out = []
for i in range(11):
x = L * i / 10.0
net = section.NetSection(panel, x)
out.append((x, net))
return out
def key_stations(panel):
"""L/20 plus every opening-edge discontinuity, Eriksson's own spacing.
See `knowledge/library/notes/E1-wpb10-eriksson-package.md` SS2: "Station
spacing is L/20 plus extra rows at every geometric discontinuity,
printed as an adjacent pair straddling the feature." `discontinuities()`
is the same function `panellift`'s own station table uses for the same
purpose -- one place decides where a section jumps, not two.
"""
L = panel.length_ft
xs = set(round(L * i / 20.0, 6) for i in range(21))
for x in section.discontinuities(panel):
xs.add(round(x, 6))
return sorted(xs)
def capacity_pair(panel, fc_psi, x):
"""(sagging point, hogging point) at station x, this concrete strength.
Two calls because this page does not know a real bending moment at x --
there is no load case built (see the module docstring) -- so it cannot
know which side is really in tension. Both signs are computed instead of
guessing one: `moment_lbft` here is a bare +-1, used ONLY for its sign
(`capacity.capacity_at` reads `moment_lbft >= 0` to choose the tension
face and nothing else off its magnitude for M_cr / phi*Mn / f_se -- see
that function's own docstring). `shear_lb=0.0` for the same reason: this
page prints phi*Vn, the capacity, never V/phi*Vn, a ratio against a
demand nobody computed.
"""
stage = Stage(name='capacity check', multiplier=1.0, fc_psi=fc_psi)
sag = capacity.capacity_at(panel, stage, x, moment_lbft=1.0, shear_lb=0.0)
hog = capacity.capacity_at(panel, stage, x, moment_lbft=-1.0, shear_lb=0.0)
return sag, hog
def _cell(value, digits, reason):
"""A computed cell: the number, or a dash carrying its own reason.
Never a bare 0.0 for "could not compute" -- that is
`capacity.CapacityPoint`'s own discipline (see its docstring: "It is
never 0.0 and it is never the previous station's value") and this page
keeps it rather than papering over it with Eriksson's convention of
printing an exact zero at a station with no developed strand. The two
read the same way (there is nothing here), spelled differently on
purpose -- see the Results section's own note.
"""
if value is None:
title = ' title="{0}"'.format(R.esc(reason)) if reason else ''
return '<span class="dash"{0}>&mdash;</span>'.format(title)
return R.n(value, digits)
def _text_cell(text, reason):
"""Like `_cell`, for a STRING value (e.g. `DetailedShearPoint.governed_by`)
rather than a number -- same could-not-judge discipline, no `R.n()` call
that would choke on a non-numeric value.
"""
if text is None:
title = ' title="{0}"'.format(R.esc(reason)) if reason else ''
return '<span class="dash"{0}>&mdash;</span>'.format(title)
return R.esc(text)
# ------------------------------------------------------------------ CSS/JS
EXTRA_CSS = """
.demo-banner{border:1px solid var(--accent);border-radius:8px;
padding:12px 16px;margin:0 0 18px;background:var(--panel);font-size:13px}
.demo-banner b{color:var(--accent)}
.legend{font-size:12px;color:var(--muted);margin:0 0 20px;line-height:2.1}
.fwrap{display:inline-flex;align-items:center;gap:6px;flex-wrap:nowrap;
white-space:nowrap}
.finput{background:var(--bg);color:var(--fg);border:1px solid var(--line);
border-radius:5px;padding:3px 7px;font:inherit;
font-variant-numeric:tabular-nums}
.finput:focus{outline:2px solid var(--accent);outline-offset:1px}
table.kv td{padding:5px 10px}
table.kv th{width:1%;white-space:nowrap;padding-right:18px}
.fieldnote{color:var(--muted)}
.chip{display:inline-block;font-size:10px;letter-spacing:.04em;
text-transform:uppercase;font-weight:700;border-radius:4px;
padding:2px 7px;white-space:nowrap;vertical-align:middle}
.chip-deck{background:var(--accent);color:#fff}
.chip-spec{background:transparent;border:1px solid var(--accent);
color:var(--accent)}
.chip-knowledge{background:var(--ok);color:#fff}
.chip-computed{background:var(--panel);border:1px solid var(--muted);
color:var(--muted)}
.chip-absent{background:transparent;border:1px dashed var(--fail);
color:var(--fail)}
.chip-placeholder{background:transparent;border:1px dashed var(--warn);
color:var(--warn)}
.chip-planned{background:transparent;border:1px dotted var(--warn);
color:var(--warn)}
.chip-edited{background:var(--warn);color:#fff}
.recess{fill:none;stroke:var(--warn);stroke-width:1.2;stroke-dasharray:3 2}
table.cells td{padding:4px 8px}
table.cells th{padding:4px 8px}
.dash{color:var(--muted);border-bottom:1px dotted var(--muted);cursor:help}
.placeholder-box{border:1px dashed var(--warn);border-radius:8px;
padding:12px 14px;color:var(--fg);background:var(--bg)}
.placeholder-box b{color:var(--warn)}
.notjudged-box{border:1px solid var(--fail);border-radius:8px;
padding:12px 14px;color:var(--fg);background:var(--bg)}
.notjudged-box b{color:var(--fail)}
.ratio-fail{color:var(--fail);font-weight:700}
.ratio-ok{color:var(--ok)}
.closure-ok{color:var(--ok)}
/* ------------------------------------------- read-only results page (2026-09) --
`panellift.report`'s CSS (embedded above as `R.CSS`) already carries `.OK`
/ `.WARN` / `.FAIL`, `.verdict`, `.stageboxes` / `.stagebox` and the print
media block Panel Lift Check's own report uses -- the additions below are
only what that stylesheet has no reason to define: a PASS colour (this
page's own vocabulary; `R.CSS` only ever says OK), the muted "from model" /
"computed" provenance word `rval()` draws instead of a chip, and the small
scope-statement box that replaces the stale banner. */
.PASS{color:var(--ok)}
.stagebox.PASS{border-left:4px solid var(--ok)}
.prov{font-size:10px;color:var(--muted);margin-left:6px;font-weight:400;
text-transform:none;letter-spacing:0;border-bottom:1px dotted var(--muted);
cursor:help}
.rval{font-variant-numeric:tabular-nums}
.scope-note{border:1px solid var(--line);border-radius:8px;padding:12px 16px;
margin:10px 0 18px;background:var(--panel);font-size:13px;color:var(--fg)}
.sec-tag{font-size:11px;font-weight:600;text-transform:none;
letter-spacing:.01em}
"""
EDIT_JS = """
function wtEdited(id){
var chip = document.getElementById(id + '-chip');
if(chip){ chip.textContent = 'EDITED'; chip.className = 'chip chip-edited'; }
}
"""
_WILLTOOLS_CSS_PATH = os.path.join(HERE, 'pa_web', 'static', 'willtools.css')
_willtools_css_cache = None
_CSS_COMMENT_RE = re.compile(r'/\*.*?\*/', re.DOTALL)
def _willtools_css():
"""The 前端's own stylesheet, read once and cached -- see `_report_css`.
CSS `/* ... */` comments are stripped before caching: this file's own
tests (and the sibling `WillTools.extension` suite that renders this
page and asserts it carries no CJK character -- an IronPython/Revit
console safety net, see CLAUDE.md sec.3) refuse any Chinese character on
the page, and `willtools.css`'s own header comment names the 前端 in
Chinese. Stripping comments is correct on its own terms too: nothing
reads them at runtime, so this is free (and the embedded page is a
little smaller for it), not a workaround bent around the test.
"""
global _willtools_css_cache
if _willtools_css_cache is None:
try:
with io.open(_WILLTOOLS_CSS_PATH, 'r', encoding='utf-8') as fh:
_willtools_css_cache = _CSS_COMMENT_RE.sub('', fh.read())
except (IOError, OSError): # pragma: no cover
_willtools_css_cache = ''
return _willtools_css_cache
def _report_css():
"""Every stylesheet the results page needs, INLINED so the file opens
standalone from disk -- Revit's WebView2 window and a plain browser both
load it as a bare `file://` path or through pa_web's own
`/results/<mark>.html` route, neither of which can fetch an external
stylesheet relative to some other served root. Three layers, in
override order: the shared 前端 look (`willtools.css`, the tokens/type
every future tool's page adopts too), the diagram/table/`<details>`/
print styling `panellift.report` already built and this page has always
embedded (`R.CSS`), and this page's own small read-only-report additions
(`EXTRA_CSS`, above).
"""
return _willtools_css() + R.CSS + EXTRA_CSS
# ------------------------------------------------------------------ sections
def sec_general(job, panel, spec, spec_path, spec_design, spec_reason, know):
src = job.get('source') or {}
rows = [
field_row('Mark', panel.mark, 'deck'),
field_row('Design number', spec_design or panel.design_number or '',
'deck' if not spec else 'spec'),
]
if spec:
rows.append(field_row(
'Piece description', spec.get('piece_description'), 'spec',
'design extract: {0}'.format(spec_path)))
sinfo = spec.get('source') or {}
rows.append(note_row(
'Design extract provenance',
'sheet {0}, extractor {1}, extracted {2}{3}'.format(
sinfo.get('sheet') or '?', sinfo.get('extractor') or '?',
sinfo.get('extracted') or '?',
'' if sinfo.get('reviewed_by') else
' -- NOT reviewed by a person yet')))
else:
rows.append(note_row('Design extract', spec_reason))
rows.append(field_row('Model', src.get('model') or '', 'deck'))
plant = deckcheck.knowledge.get(know or {}, '_plant_id') if know else None
rows.append(field_row('Plant', plant or '', 'knowledge' if plant else
'absent',
'job.plant, resolved the same two-pass way '
'knowledge.plant_id() does off a live doc'))
return table(rows)
def sec_extents(panel, spec, readonly=False):
rows = [
field_row('Length', R.fmt_ft_in(panel.length_ft), 'deck',
readonly=readonly),
field_row('Width', R.fmt_ft_in(panel.width_ft), 'deck',
readonly=readonly),
field_row('Thickness (in)', _in_val(panel.thickness_in), 'deck',
readonly=readonly),
select_row('Finish', panel.finish, ['smooth', 'exposed'], 'deck',
readonly=readonly),
select_row('Product type', panel.product_type,
['flat', 'flat_reveal', 'fluted', 'sculptured'], 'deck',
readonly=readonly),
]
if spec:
env = spec.get('envelope') or {}
for axis, actual in (('length', panel.length_ft),
('width', panel.width_ft)):
block = env.get(axis) or {}
lo, hi = block.get('min_ft'), block.get('max_ft')
in_range = (lo is not None and hi is not None and
lo - 1e-6 <= actual <= hi + 1e-6)
rows.append(note_row(
'{0} envelope (spec)'.format(axis.capitalize()),
'{0} to {1} -- this piece is {2} the envelope'.format(
block.get('min_text') or R.fmt_ft_in(lo),
block.get('max_text') or R.fmt_ft_in(hi),
'inside' if in_range else 'OUTSIDE')))
body = ['<figure>{0}</figure>'.format(panel_plan_svg(panel)),
table(rows)]
if panel.blockouts:
cells = ['<tr><th>Name</th><th>x0 (ft)</th><th>y0 (ft)</th>'
'<th>Length (ft)</th><th>Width (ft)</th></tr>']
for b in panel.blockouts:
cells.append('<tr><td>{0}</td><td>{1}</td><td>{2}</td>'
'<td>{3}</td><td>{4}</td></tr>'.format(
R.esc(b.name),
finput(_ft_val(b.x0), 'deck', width=6,
readonly=readonly, word=False),
finput(_ft_val(b.y0), 'deck', width=6,
readonly=readonly, word=False),
finput(_ft_val(b.length), 'deck', width=6,
readonly=readonly, word=False),
finput(_ft_val(b.width), 'deck', width=6,
readonly=readonly, word=False)))
body.append('<h3>Blockouts{0}</h3>'.format(
' <span class="prov">from model</span>' if readonly else '')
+ table(cells, 'cells'))
if panel.recesses:
cells = ['<tr><th>Name</th><th>x0 (ft)</th><th>y0 (ft)</th>'
'<th>Length (ft)</th><th>Width (ft)</th>'
'<th>Concrete behind (in)</th></tr>']
for rc in panel.recesses:
cells.append('<tr><td>{0}</td><td>{1}</td><td>{2}</td>'
'<td>{3}</td><td>{4}</td><td>{5}</td></tr>'.format(
R.esc(rc.name),
finput(_ft_val(rc.x0), 'deck', width=6,
readonly=readonly, word=False),
finput(_ft_val(rc.y0), 'deck', width=6,
readonly=readonly, word=False),
finput(_ft_val(rc.length), 'deck', width=6,
readonly=readonly, word=False),
finput(_ft_val(rc.width), 'deck', width=6,
readonly=readonly, word=False),
finput(_in_val(rc.concrete_in), 'deck', width=6,
readonly=readonly, word=False)))
body.append('<h3>Recesses (blind pockets -- not cut into the '
'section; see model.Recess){0}</h3>'.format(
' <span class="prov">from model</span>' if readonly
else '') + table(cells, 'cells'))
return ''.join(body)
def sec_materials(panel, spec, know, readonly=False):
rows = [
field_row("f'c (psi)", panel.fc_psi, 'deck', readonly=readonly),
field_row("f'ci (psi)", panel.fci_psi, 'deck', readonly=readonly),
field_row('Unit weight (pcf)', panel.unit_weight_pcf, 'deck',
readonly=readonly),
]
if panel.prestress:
rows.append(field_row('Strand f_pu (ksi)', panel.prestress.fpu_ksi,
'deck' if panel.prestress.fpu_source == 'job'
else 'computed',
'fpu_source: {0}'.format(
panel.prestress.fpu_source),
readonly=readonly))
if spec:
rows.append(note_row(
'Weight class (spec)', spec.get('concrete', {}).get(
'weight_class') or 'not stated on the design summary'))
return table(rows)
def sec_prestress(panel, spec, know, readonly=False):
pre = panel.prestress
if pre is None:
body = ['<p class="fieldnote">This deck carries no prestress block '
'at all. Per <code>model.Prestress</code>\'s own contract '
'that means either a genuinely non-prestressed piece, or '
'one the exporter did not find strand on -- the deck alone '
'does not distinguish the two.</p>']
if spec and (spec.get('reinforcing') or {}).get('strand_groups'):
sg = spec['reinforcing']['strand_groups']
body.append('<p class="fieldnote"><b>Worth a second look:</b> '
'the design extract for this design states {0} '
'strand(s) per group below, and the job\'s own '
'knowledge layer may explain the gap rather than a '
'miss -- see the note beside the table.</p>'
.format(sum(g.get('qty') or 0 for g in sg)))
body.append(_spec_strand_table(sg, readonly=readonly))
per_piece = k(know, 'legend.printed_strand_count_is_per_piece',
True)
body.append(table([note_row_html(
'legend.printed_strand_count_is_per_piece',
'{0} -- {1}'.format(
per_piece,
'the printed count IS a per-piece quantity, so a piece '
'with none is a real mismatch worth checking' if
per_piece else
'the printed count is the design\'s full-width MAXIMUM, '
'not a per-piece quantity -- a narrower piece (this one '
'is {0} of an envelope up to {1}) legitimately carries '
'fewer strands, or none'.format(
R.fmt_ft_in(panel.width_ft),
(spec.get('envelope', {}).get('width', {})
.get('max_text') or '?'))),
'knowledge', readonly=readonly)]))
return ''.join(body)
rows = [
field_row('Strand diameter (in)', pre.diameter_in, 'deck',
readonly=readonly),
field_row('Prestress loss (%)', pre.loss_pct, 'deck',
'current knowledge default: {0} ({1})'.format(
k(know, 'handling.prestress_loss_pct', 8.0),
'matches this deck' if
pre.loss_pct == k(know, 'handling.prestress_loss_pct',
8.0) else 'DIFFERS from this deck'),
readonly=readonly),
field_row('Transfer length override (in)',
_in_val(pre.transfer_length_in),
'deck' if pre.transfer_length_in is not None else 'absent',
'None means ACI 318 f_se*db/3, computed below',
readonly=readonly),
field_row('Computed transfer length l_t (in)',
_in_val(prestress.transfer_length_in(pre)), 'computed',
"ACI 318 f_se*db/3, from this deck's own jacking force",
readonly=readonly),
]
if spec:
sg = (spec.get('reinforcing') or {}).get('strand_groups') or []
n_spec = sum(g.get('qty') or 0 for g in sg)
rows.append(note_row(
'Strand count cross-check',
'{0} strand(s) in the deck vs {1} in the design extract -- '
'{2}'.format(
len(pre.strands), n_spec,
'consistent' if len(pre.strands) == n_spec else
'DIFFER (a narrower piece than the design\'s full width can '
'legitimately carry fewer -- see legend.'
'printed_strand_count_is_per_piece)')))
body = [table(rows)]
cells = ['<tr><th>Mark</th><th>y (ft)</th><th>z (in)</th>'
'<th>Area (in&sup2;)</th><th>Initial pull (lb)</th>'
'<th>f_se (ksi)</th></tr>']
for s in pre.strands:
fse = round(prestress.effective_lb(s, pre) / s.area_in2 / 1000.0, 1) \
if s.area_in2 else None
cells.append(
'<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td>'
'<td>{4}</td><td>{5}</td></tr>'.format(
R.esc(s.label or '-'),
finput(_ft_val(s.y), 'deck', width=6, readonly=readonly,
word=False),
finput(_in_val(s.z_in), 'deck', width=6, readonly=readonly,
word=False),
finput(s.area_in2, 'deck', width=6, readonly=readonly,
word=False),
finput(s.initial_pull_lb, 'deck', width=8, readonly=readonly,
word=False),
fmt(fse, 1)))
body.append('<h3>Strand layout{0}</h3>'.format(
' <span class="prov">from model</span>' if readonly else '')
+ table(cells, 'cells'))
if spec:
body.append(_spec_strand_table(
(spec.get('reinforcing') or {}).get('strand_groups') or [],
readonly=readonly))
return ''.join(body)
def _spec_strand_table(groups, readonly=False):
if not groups:
return ''
cells = ['<tr><th>Qty</th><th>Diameter (in)</th><th>Grade (ksi)</th>'
'<th>Initial tension (lb)</th><th>Role</th></tr>']
for g in groups:
cells.append(
'<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td>'
'<td>{4}</td></tr>'.format(
finput(g.get('qty'), 'spec', width=6, readonly=readonly,
word=False),
finput(_in_val(g.get('dia_in')), 'spec', width=6,
readonly=readonly, word=False),
finput(g.get('grade_ksi'), 'spec', width=6,
readonly=readonly, word=False),
finput(g.get('it_lb'), 'spec', width=8, readonly=readonly,
word=False),
finput(g.get('role'), 'spec', width=8, readonly=readonly,
word=False)))
return '<h3>Strand groups (design extract)</h3>' + table(cells, 'cells')
def sec_mild_bars(deck, readonly=False):
"""`reinforcement.mild_bars`, as entered, and which rows entered the
flexural capacity. '' for a deck with no such block (every deck before
PANELANALYSIS-4) -- appended to Prestress/Reinforcement only when there
is something to show.
"""
bars = schema.mild_bars(deck)
if not bars:
return ''
rows = ['<tr><th>#</th><th>Size</th><th>Count</th><th>Area (in&sup2;)'
'</th><th>Depth (in)</th><th>Direction</th><th>From (ft)</th>'
'<th>To (ft)</th><th>In capacity?</th></tr>']
for i, b in enumerate(bars, 1):
longitudinal = b['direction'] == 'Longitudinal'
if longitudinal and b['depth_in'] is not None:
status = '<span class="ratio-ok">yes</span>'
elif not longitudinal:
status = ('<span class="fieldnote">transverse &mdash; listed, '
'not used</span>')
else:
status = ('<span class="ratio-fail">not in the capacity (no '
'depth given)</span>')
rows.append(
'<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td>'
'<td>{4}</td><td>{5}</td><td>{6}</td><td>{7}</td>'
'<td>{8}</td></tr>'.format(
i, finput(b['size'], 'deck', width=6, readonly=readonly,
word=False),
finput(b['count'], 'deck', width=4, readonly=readonly,
word=False),
finput(b['area_in2'], 'deck', width=6, readonly=readonly,
word=False),
finput(_in_val(b['depth_in']), 'deck', width=6,
readonly=readonly, word=False),
finput(b['direction'], 'deck', width=11, readonly=readonly,
word=False),
finput(_ft_val(b['from_ft']), 'deck', width=6,
readonly=readonly, word=False),
finput(_ft_val(b['to_ft']), 'deck', width=6,
readonly=readonly, word=False), status))
body = ['<h3>Mild bars (as entered){0}</h3>'.format(
' <span class="prov">from model</span>' if readonly else ''),
table(rows, 'cells')]
fy = schema.mild_bar_grade_ksi(deck)
body.append(
'<p class="fieldnote">Longitudinal bars with a depth enter the '
'flexural capacity (<code>capacity.mild_flexure_at</code>, combined '
'with any strand by strain compatibility) at f<sub>y</sub> = {0} ksi '
'(<code>settings.rebar_fy_ksi</code>, default 60 &mdash; this block '
'carries no per-bar grade). A bar with no depth given is reported, '
'never assumed into the capacity, and stays that way until the '
'field model measures one. Transverse bars are listed only; the '
'out-of-plane check does not use them.</p>'.format(R.n(fy, 1)))
return ''.join(body)
def note_row_html(label, html_value, source, readonly=False):
"""A prose row with its source named -- a chip normally, or (readonly)
just the muted provenance word with no dash, since this is prose, not a
possibly-missing field (`rval`'s value-or-dash choice does not apply).
"""
if readonly:
word = PROV_WORD.get(source)
tail = '<span class="prov">{0}</span>'.format(R.esc(word)) if word \
else ''
else:
tail = chip(source)
return '<tr><th>{0}</th><td>{1} {2}</td></tr>'.format(
R.esc(label), R.notate(html_value), tail)
def sec_structural_model(job, panel):
body = []
stages = job.get('stages') or []
cells = ['<tr><th>Stage</th><th>Span</th><th>Rig mode</th>'
'<th>PCI case</th><th>Picks / supports</th></tr>']
for st in stages:
if st.get('supports') is not None:
picks = ' '.join(
finput(_ft_val(x), 'deck', width=6) for x in st['supports'])
picks += '&nbsp;(dunnage support x)'
else:
marks = st.get('lifters')
by_mark = {l['mark']: l for l in panel_lifters_raw(job)}
if marks is None:
picks = 'every lifter on the piece'
else:
picks = ', '.join(
'{0} ({1},{2})'.format(
m, R.fmt_ft_in(by_mark.get(m, {}).get('x')),
R.fmt_ft_in(by_mark.get(m, {}).get('y')))
for m in marks)
cells.append('<tr><td>{0}</td><td>{1}</td><td>{2}</td>'
'<td>{3}</td><td>{4}</td></tr>'.format(
R.esc(st.get('name') or '?'),
R.esc(st.get('span') or 'length'),
R.esc(st.get('rig_mode') or '(job default)'),
R.esc(st.get('pci_case') or '?'), picks))
body.append('<h3>Rigging, per stage (deck)</h3>' + table(cells, 'cells'))
body.append('<h3>Lifting inserts</h3>')
lcells = ['<tr><th>Mark</th><th>Family</th><th>x (ft)</th>'
'<th>y (ft)</th><th>Capacity (lb)</th><th>Edge?</th></tr>']
for l in panel.lifters:
lcells.append(
'<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td>'
'<td>{4}</td><td>{5}</td></tr>'.format(
R.esc(l.mark), R.esc(l.family),
finput(_ft_val(l.x), 'deck', width=6),
finput(_ft_val(l.y), 'deck', width=6),
finput(l.capacity_lb, 'deck', width=7),
'yes' if l.on_edge else 'no'))
body.append(table(lcells, 'cells'))
body.append('<h3>In-place bearing (not built into any stage yet)</h3>')
body.append(
'<p class="fieldnote">Whether a support location was read off the '
'model or not, it gets a place to type or correct it here -- the '
'owner\'s ruling. In-place bearing is not read at all yet (nothing '
'walks the model for it); the rows below are always present and '
'always editable, whether or not a number was ever supplied.</p>')
bcells = ['<tr><th>Bearing</th><th>x (ft)</th><th>Width (in)</th>'
'<th>Condition</th></tr>']
for i in (1, 2):
bcells.append(
'<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td></tr>'
.format(i, finput(None, 'absent', 'never read off the model',
width=6),
finput(None, 'absent', width=6),
fselect(None, ['', 'pinned', 'roller', 'fixed'],
'absent')))
body.append(table(bcells, 'cells'))
body.append('<h3>Connections (unread)</h3>')
conn = (job.get('source') or {}).get('connectors') or {}
why = conn.get('why') or ''
ccells = ['<tr><th>Field</th><th>Value</th></tr>']
for key in ('family', 'count', 'per_row', 'spacing_in',
'capacity_tension_lb', 'capacity_shear_lb',
'stiffness_lb_per_in', 'declared_action'):
ccells.append('<tr><td>{0}</td><td>{1}</td></tr>'.format(
R.esc(key), finput(conn.get(key), 'absent', width=10)))
body.append(table(ccells, 'cells'))
if why:
body.append('<p class="fieldnote">{0}</p>'.format(R.notate(why)))
return ''.join(body)
def panel_lifters_raw(job):
return (job.get('panel') or {}).get('lifters') or []
def sec_settings(panel, know):
rows = []
stages = [] # filled by caller via job, kept generic here
rows.append(field_row(
"Handling tensile limit, x*sqrt(f'c)",
k(know, 'handling.tension_limit_sqrt_fc', 5.0), 'knowledge',
'current merged knowledge; a stage in the deck may carry its own, '
'see the Structural Model / Loading sections'))
rows.append(field_row(
"Handling compression limit ratio, x*f'c", k(
know, 'handling.compression_limit_ratio', 0.6), 'knowledge'))
rows.append(field_row(
'Prestress loss (%), current default',
k(know, 'handling.prestress_loss_pct', 8.0), 'knowledge'))
rows.append(field_row(
'phi, flexure (tension-controlled)', 0.90, 'computed',
"ACI 318-19 Table 21.2.2, this tool's own constant "
'(panellift.pci.PHI_FLEXURE_TENSION_CONTROLLED). Constant -- NOT '
'ramped 0.75 to 0.90 across the transfer/development length the '
'way Eriksson does per ACI 21.2.3; see E1-wpb10-eriksson-package.md '
'SS4.1. That ramp is not implemented here.'))
rows.append(field_row(
'phi, shear', 0.75, 'computed',
'ACI 318-19 Table 21.2.1 (panellift.pci.PHI_SHEAR)'))
rows.append(field_row(
'Development length multiplier (strand)', 1.0, 'computed',
"this tool applies none; Eriksson's own Development Multipliers "
'setting applies 1.3 to both the transfer and development length '
'-- see E1-wpb10-eriksson-package.md SS4.1. Not implemented here.'))
rows.append(note_row(
'Percent composite (stripping/transport/erection/in-place)',
'not applicable -- this is a solid section (product_type={0}), not '
'a sandwich panel with wythes to interpolate between'.format(
panel.product_type)))
rows.append(note_row(
'Structural analysis (2nd order, cracking method)',
'not computed -- P-Delta / cracked-section analysis needs a real '
'load case; see the placeholder list below'))
return table(rows)
def sec_loading(panel, readonly=False):
"""Self weight, always. The stale 'wind is planned, nothing is built'
tail is for the OLD demo page only (`readonly=False`, the `panellift/1`
form): the analysis page has its own real wind sections (`sec_wind_zones`
/ `sec_in_place_wind`), built off THIS deck's `loading.wind`, so repeating
a blanket 'not computed' here would be exactly the stale claim the
results-page redesign exists to remove -- see the module docstring.
"""
geo_lb, stated_lb, rel_err = section.weight_reconciliation(panel)
psf = section.unit_weight_psf(panel)
rows = [
field_row('Stated weight (deck, lb)', round(stated_lb, 0)
if stated_lb else None, 'deck', readonly=readonly),
field_row('Geometric weight (computed, lb)', round(geo_lb, 0),
'computed', 'section.geometric_weight_lb -- rebuilt from '
'length x width x thickness minus full-depth blockouts',
readonly=readonly),
]
if stated_lb:
rows.append(note_row(
'Reconciliation',
'{0:+.1%} relative to the stated weight -- a mismatch beyond '
"knowledge's weight_tolerance is what liftexport refuses the "
'export over'.format(rel_err)))
rows.append(field_row('Unit weight (psf of the panel face)',
round(psf, 1), 'computed', readonly=readonly))
rows.append(field_row('Self weight (plf, uniform -- no openings cut '
'the section on this piece' if not
panel.blockouts else
'Self weight (plf, varies -- see blockouts)',
round(section.line_load_plf(panel, 0.0), 1),
'computed', readonly=readonly))
body = [table(rows)]
if readonly:
return ''.join(body)
body.append('<h3>Wind / lateral inputs (planned)</h3>')
body.append('<p class="fieldnote">Named by the owner as a future input '
'source, not built here: a <code>wallanalysis</code> '
'Project Config module supplying wind speed, K<sub>zt</sub>, '
'the ASCE edition and a parameter table he will supply '
'later.</p>')
prows = [
field_row('Wind speed (mph)', None, 'planned',
'planned Project Config module: wallanalysis'),
field_row('K_zt (topographic factor)', None, 'planned'),
field_row('ASCE edition', None, 'planned'),
field_row('Risk category', None, 'planned'),
field_row('Exposure category', None, 'planned'),
]
body.append(table(prows))
body.append(
'<div class="placeholder-box"><b>Not computed:</b> live load, wind '
'(C&amp;C and MWFRS), seismic and earth load cases; ACI 318 '
'strength/service load combinations; P-Delta / second-order '
'effects; slenderness; axial load-moment interaction. Self weight '
'is the only load this page computes (above and in Results) -- '
'this demo reads the model and shows the shape; the design engine '
'is not built.</div>')
return ''.join(body)
def sec_section_properties(panel):
rows = ['<tr><th>Loc (ft)</th><th>Width (in)</th><th>Area (in&sup2;)'
'</th><th>CGy (in)</th><th>I<sub>z</sub> (in&#8308;)</th>'
'<th>S<sub>z</sub> (in&sup3;)</th></tr>']
for x, net in section_properties_rows(panel):
rows.append(
'<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td>'
'<td>{4}</td><td>{5}</td></tr>'.format(
R.fmt_ft_in(x), R.fmt_in(net.width_in), R.n(net.area_in2, 1),
R.fmt_in(net.cy_in), R.n(net.iz_in4, 0), R.n(net.sz_in3, 1)))
note = ('<p class="fieldnote">L/10 stations, Eriksson\'s own "Design '
'Parameters" convention (E1-wpb10-eriksson-package.md SS2.6). '
'From <code>panellift.section.NetSection</code> -- weak-axis '
'bending through the thickness, openings removed.</p>')
return note + '<div class="tablewrap">' + table(rows, 'stations') + \
'</div>'
def sec_capacity(panel):
if panel.prestress is None or not panel.prestress.strands:
return ('<div class="placeholder-box">This piece carries no '
'prestress in the deck, and <code>panellift.capacity</code> '
'is strand-only by construction (no mild-steel path exists '
'in the job schema -- see capacity.py\'s own "WHY STRAND '
'ONLY" note). Flexural capacity is not computed for it. '
'phi*V<sub>n</sub> (concrete alone) does not need '
'prestress and is shown below.</div>' +
sec_shear_only(panel))
groups = []
if panel.fci_psi:
groups.append(('At release, f&prime;<sub>ci</sub> = {0} psi -- '
'governs stripping'.format(R.n(panel.fci_psi)),
panel.fci_psi))
if panel.fc_psi and panel.fc_psi != panel.fci_psi:
groups.append(('At 28-day strength, f&prime;<sub>c</sub> = {0} psi '
'-- governs transportation / erection'.format(
R.n(panel.fc_psi)), panel.fc_psi))
body = ['<p class="fieldnote">L/20 stations plus any opening-edge '
'discontinuity (E1-wpb10-eriksson-package.md SS2). Both bending '
'senses are shown at every station because this page computes '
'no real moment to pick one from -- see the module docstring. '
'A station with no developed strand shows a dash with its '
'reason rather than a printed zero: this tool never fabricates '
'a 0.00 for "could not compute" (capacity.CapacityPoint\'s own '
'discipline); Eriksson prints an exact zero at the same '
'stations for the same reason, notation only.</p>']
xs = key_stations(panel)
for title, fc in groups:
rows = ['<tr><th rowspan="2">Loc (ft)</th>'
'<th colspan="3">Sagging (&theta;=0&deg;)</th>'
'<th colspan="3">Hogging (&theta;=180&deg;)</th>'
'<th rowspan="2">f<sub>se</sub> (ksi)</th>'
'<th rowspan="2">&phi;V<sub>n</sub> (k)</th></tr>'
'<tr><th>&phi;</th><th>&phi;M<sub>n</sub> (k-ft)</th>'
'<th>M<sub>cr</sub> (k-ft)</th>'
'<th>&phi;</th><th>&phi;M<sub>n</sub> (k-ft)</th>'
'<th>M<sub>cr</sub> (k-ft)</th></tr>']
for x in xs:
sag, hog = capacity_pair(panel, fc, x)
fse = sag.fse_ksi if sag.fse_ksi is not None else hog.fse_ksi
rows.append(
'<tr><td>{0}</td>'
'<td>{1}</td><td>{2}</td><td>{3}</td>'
'<td>{4}</td><td>{5}</td><td>{6}</td>'
'<td>{7}</td><td>{8}</td></tr>'.format(
R.fmt_ft_in(x),
_cell(sag.phi, 2, sag.reason(capacity.PHI_MN)),
_cell(sag.phi_mn_lbft and sag.phi_mn_lbft / 1000.0, 2,
sag.reason(capacity.PHI_MN)),
_cell(sag.mcr_lbft and sag.mcr_lbft / 1000.0, 2,
sag.reason(capacity.MCR)),
_cell(hog.phi, 2, hog.reason(capacity.PHI_MN)),
_cell(hog.phi_mn_lbft and hog.phi_mn_lbft / 1000.0, 2,
hog.reason(capacity.PHI_MN)),
_cell(hog.mcr_lbft and hog.mcr_lbft / 1000.0, 2,
hog.reason(capacity.MCR)),
_cell(fse, 1, sag.reason(capacity.FSE)),
_cell(sag.phi_vn_lb and sag.phi_vn_lb / 1000.0, 1,
sag.reason(capacity.PHI_VN))))
body.append('<h3>{0}</h3><div class="tablewrap">{1}</div>'.format(
title, table(rows, 'stations')))
return ''.join(body)
def sec_shear_only(panel):
xs = key_stations(panel)
rows = ['<tr><th>Loc (ft)</th><th>&phi;V<sub>n</sub> (k)</th></tr>']
fc = panel.fc_psi or panel.fci_psi or 0.0
stage = Stage(name='shear', multiplier=1.0, fc_psi=fc)
for x in xs:
pt = capacity.capacity_at(panel, stage, x, moment_lbft=1.0,
shear_lb=0.0)
rows.append('<tr><td>{0}</td><td>{1}</td></tr>'.format(
R.fmt_ft_in(x), _cell(pt.phi_vn_lb and pt.phi_vn_lb / 1000.0, 1,
pt.reason(capacity.PHI_VN))))
return '<div class="tablewrap">' + table(rows, 'stations') + '</div>'
def sec_placeholders():
items = [
'In-place load cases: live, wind (C&C and MWFRS), seismic, earth',
'ACI 318 strength and service load combinations',
'P-Delta / second-order effects, slenderness',
'Axial load-moment (P-M) interaction',
'Horizontal shear / connector demand (also blocked on '
'source.connectors being unread -- see Structural Model)',
'In-place strength and service summary tables',
'Bearing / in-place support reactions (input rows exist; nothing '
'computes off them yet)',
'A wallanalysis Project Config module: wind speed, Kzt, ASCE '
'edition, a parameter table -- named by the owner, not built',
]
return '<ul>{0}</ul>'.format(
''.join('<li>{0}</li>'.format(R.notate(i)) for i in items))
# ===================================================== panelanalysis/1 solve
#
# The Handling model, actually solved. Everything below reuses `panellift`'s
# engine and adds no mechanics of its own: `statics.analyse` with a stage whose
# `supports` are set IS `_support_rig(supports)` + `solve_static` + `diagram`
# (see `statics.analyse`), and `capacity.capacity_at` is the same per-station
# capacity the handling report computes. This tool's job is to lay the answers
# out in the Eriksson sections and to refuse loudly where the engine refuses.
def solve_handling(deck, panel):
"""(analysis, refusal) -- the self-weight Handling solve on a deck.
On success, `analysis` is a `panellift.statics.Analysis` and `refusal` is
None. On a deck the engine will not solve -- fewer than two supports, or a
support off the piece -- `analysis` is None and `refusal` is the engine's
OWN message, so the page can print that exact sentence as NOT JUDGED. The
<2-supports case raises `statics.analyse`'s "a supported stage needs at
least two supports"; nothing here fabricates a result in its place.
"""
try:
stage = schema.handling_stage(deck, panel)
analysis = statics.analyse(panel, stage, schema.handling_rigging())
return analysis, None
except (ValueError, schema.DeckError) as exc:
return None, str(exc)
def solve_stage(panel, stage):
"""(analysis, refusal) for ONE already-built handling stage.
The multi-stage sibling of `solve_handling`: each stage runs on its own
multiplier and strength (the strength sets the elastic modulus for the
indeterminate load share and the f'c the capacity is measured at), so they
cannot be read off one solve. Same loud-refusal contract -- the engine's
own words come back as `refusal`, never a traceback.
"""
try:
analysis = statics.analyse(panel, stage, schema.handling_rigging())
return analysis, None
except (ValueError, schema.DeckError) as exc:
return None, str(exc)
def _ratio_cell(value, reason, fail_above=capacity.RATIO_FAIL_ABOVE):
"""A demand/capacity ratio cell: the number, a FAIL flag, or a dash+reason.
Mirrors `_cell`: a ratio that could not be taken is a dash carrying its
reason (a 0/0 free-end residue, a strand not developed, no prestress), and
NEVER a fabricated 0. A ratio over the ruling's 1.0 is flagged FAIL -- the
tool may not bless it (capacity.py's own ruling); the engineer decides.
"""
if value is None:
title = ' title="{0}"'.format(R.esc(reason)) if reason else ''
return '<span class="dash"{0}>&mdash;</span>'.format(title)
if value > fail_above:
return '<span class="ratio-fail">{0} FAIL</span>'.format(R.n(value, 2))
return '<span class="ratio-ok">{0}</span>'.format(R.n(value, 2))
# --------------------------------------------------------- summary / verdict
#
# The Summary card (item 3 of the results-page redesign) needs one PASS/WARN/
# FAIL verdict per solved model, and one overall verdict for the whole page.
# `capacity.RATIO_FAIL_ABOVE` (1.0) is the tool's own FAIL line and is used
# UNCHANGED here -- nothing below may turn a real FAIL into anything else.
# WARN is a report-level display convention only, not a new engineering
# ruling: it exists so a ratio that already passed but sits close to 1.0
# gets a second look on the summary card, and it never touches a ratio the
# capacity/inplace engines did not already compute.
SUMMARY_WARN_ABOVE = 0.90
def _verdict(util, fail_above=capacity.RATIO_FAIL_ABOVE,
warn_above=SUMMARY_WARN_ABOVE):
"""PASS / WARN / FAIL for one governing ratio, or None -- not judged."""
if util is None:
return None
if util > fail_above:
return 'FAIL'
if util >= warn_above:
return 'WARN'
return 'PASS'
def _overall_verdict(verdicts):
"""FAIL beats WARN beats PASS; 'NOT JUDGED' only where nothing was."""
judged = [v for v in verdicts if v]
if not judged:
return 'NOT JUDGED'
if 'FAIL' in judged:
return 'FAIL'
if 'WARN' in judged:
return 'WARN'
return 'PASS'
def _sec_tag(check_html, util, x, verdict):
"""The one-liner pinned to a collapsed section's own header -- e.g.
'tension 0.20 at 5'-7 5/8" -- PASS'. `_dsec`'s `tag` argument.
"""
if verdict is None:
return '<span class="sec-tag NOTRUN">not judged</span>'
return ('<span class="sec-tag {0}">{1} {2} at {3} &mdash; {0}</span>'
.format(verdict, check_html, R.n(util, 2), R.fmt_ft_in(x)))
def _notjudged_tag():
return '<span class="sec-tag NOTRUN">not judged</span>'
def _dsec(title, body_html, tag='', open_=False, cls='section'):
"""`panellift.report._sec`'s own `<details>` markup, plus an `open`
attribute `_sec` does not take -- the results page needs the governing
model's section open on load (item 5 of the redesign) and every other
section closed, which `_sec` alone cannot express. Same class names as
`_sec` (`section`, `sec-name`, `details-body`), so the CSS `_sec`-built
pages already carry (embedded via `R.CSS`) styles this identically; this
is markup only, not a reimplementation of anything `report.py` computes.
"""
head = R.esc(title)
if tag:
head = '<span class="sec-name">{0}</span>{1}'.format(head, tag)
return ('<details class="{0}"{1}><summary>{2}</summary>'
'<div class="details-body">{3}</div></details>'
.format(cls, ' open' if open_ else '', head, body_html))
def _stagebox(name, verdict, util, detail_html):
"""One Summary-card box -- same `.stagebox` shape/classes
`panellift.report._stage_box` draws for Panel Lift Check's own summary
(`OK`/`WARN`/`FAIL` left border, `NOTRUN` dashed), so the two tools'
results pages share one look without this file reimplementing that CSS.
The content differs (a generic governing check + ratio, not always a
tension psi/psi pair), so this writes its own markup against the same
classes rather than calling that private helper with numbers that would
not mean what its own docstring says they mean.
"""
if verdict is None:
return ('<div class="stagebox NOTRUN">'
'<div class="stagebox-head"><span>{0}</span>'
'<b class="notrun-tag">not judged</b></div>'
'<p class="stagebox-why">{1}</p></div>'
.format(R.esc(name), R.notate(detail_html)))
pct = '{0:.0%}'.format(util) if util is not None else '-'
return ('<div class="stagebox {0}">'
'<div class="stagebox-head"><span>{1}</span>'
'<b class="{0}">{0}</b></div>'
'<div class="stagebox-ratio"><b class="{0}">{2}</b>'
'<span> utilised</span></div>'
'<div class="stagebox-detail">{3}</div></div>'
.format(verdict, R.esc(name), pct, detail_html))
def handling_reactions_table(analysis, deck):
"""Support reactions, and the closure check that they sum to the load.
The closure -- sum(R) against the total factored self weight -- is the one
line that says the solve is real: `panellift`'s consistent-load formulation
makes it exact to numerical precision (see `statics._element_load`), and a
reader who edits the supports and re-runs watches these numbers move while
their sum does not.
"""
types = {}
for x, t in schema.supports(deck):
types.setdefault(round(x, 6), t)
rows = ['<tr><th>Support</th><th>x (ft)</th><th>Type</th>'
'<th>Reaction (lb)</th></tr>']
total_r = 0.0
for i, line in enumerate(analysis.rig.lines, 1):
t = types.get(round(line.x, 6), 'support')
rows.append('<tr><td>{0}</td><td>{1}</td><td>{2}</td>'
'<td>{3}</td></tr>'.format(
i, R.fmt_ft_in(line.x), R.esc(t),
R.n(line.reaction_lb, 0)))
total_r += line.reaction_lb
total_load = analysis.profile.total_lb
body = [table(rows, 'cells')]
diff = total_r - total_load
closed = abs(diff) <= max(1.0, 1e-6 * abs(total_load))
body.append(
'<p class="fieldnote">Sum of reactions '
'<b>{0} lb</b> against total factored self weight '
'<b>{1} lb</b> &mdash; <span class="{2}">{3}</span>. '
'Reactions must sum to the load; this is the closure that says the '
'solve is real, not that a stress passed.</p>'.format(
R.n(total_r, 0), R.n(total_load, 0),
'closure-ok' if closed else 'ratio-fail',
'closes to {0} lb'.format(R.n(abs(diff), 1)) if closed else
'DOES NOT CLOSE ({0} lb off)'.format(R.n(diff, 1))))
if analysis.weight_error:
body.append(
'<p class="fieldnote">Load rescaled to the deck\'s stated weight; '
'the geometry came out {0:+.1%} against it '
'(section.weight_reconciliation).</p>'.format(
analysis.weight_error))
return ''.join(body)
def _dense_shear_governing(analysis, panel, stage):
"""(x, DetailedShearPoint) for the worst V/phiVn over EVERY diagram station.
Parity gap #6 (docs/eriksson-wall-algorithms.md sec.3): the L/20
`key_stations` grid the demand-vs-capacity table is built on can MISS the
station where shear actually governs, because the detailed Vci/Vcw method
(unlike the near-constant simplified V_c) varies with M/V along the span
and its peak can sit off that coarse grid -- measured on MWV1.0, the
governing station is x=25.46 ft, not one of the L/20 rows. So the
GOVERNING shear is taken from a scan of `analysis.diagram.xs`, the same
dense grid `panellift`'s own per-piece checks scan (see `profile_for`'s
own docstring on why a verdict may not depend on a display stride), not
from the coarser rows the table prints. Returns (None, None) where no
station could be judged at all.
"""
xs = analysis.diagram.xs
moments = analysis.diagram.moment_lbft
shears = analysis.diagram.shear_lb
best_x, best_pt = None, None
for i, x in enumerate(xs):
pt = capacity.detailed_shear_at(panel, stage, x, moments[i], shears[i])
if pt.v_over_phi_vn is not None and (
best_pt is None or pt.v_over_phi_vn > best_pt.v_over_phi_vn):
best_x, best_pt = x, pt
return best_x, best_pt
def _stage_station_results(analysis, panel, stage, xs):
"""Per-station results for one stage, and the stage's governing check.
Returns (rows, gov) where `rows` is a list of dicts (one per station,
carrying the capacity point, the detailed Vci/Vcw shear point, the
strain-based-phi flexure and the stress point) and `gov` is
(check_name, util, x) -- the highest utilisation across the FOUR handling
checks: flexure (phi from ACI 318-14 Table 21.2.2's net-tensile-strain
transition, FIX 3), shear (the DETAILED Vci/Vcw method, FIX 6 --
`capacity.detailed_shear_at`, not `capacity_at`'s simplified V_c), and the
extreme-fibre tension and compression STRESS (FIX 1). `gov` is None where
nothing at any station could be judged.
The shear ROWS shown in `rows` are the detailed method at the same
key_stations `xs` every other column is printed at, for a table that
reads left to right at one x per row; the shear CANDIDATE that feeds
`gov` is instead the dense full-station scan (`_dense_shear_governing`),
because that coarse grid is known to miss the governing shear station
near a support -- see that function's own docstring. `capacity_at` and
`pci.concrete_shear_capacity_lb` (Panel Lift Check's own shear) are
untouched; this is Panel Analysis's shear result only.
"""
rows = []
cands = [] # (name, util, x)
for x in xs:
m_lbft = analysis.diagram.moment_at(x)
v_lb = analysis.diagram.shear_at(x)
cap = capacity.capacity_at(panel, stage, x, m_lbft, v_lb)
shear_pt = capacity.detailed_shear_at(panel, stage, x, m_lbft, v_lb)
# FIX 3: phi from the section's net tensile strain (Table 21.2.2),
# 0.65->0.90, covering the transition region capacity_at dashes. Its
# flat-0.90 value is kept for its other callers; this recomputes phi
# here on the same point.
#
# MILD-REINFORCED path: a non-prestressed (or combined) piece is what
# `capacity_at` dashes as 'no prestressing on this piece', so
# `handling_flexure_at` returns nothing for it and the flexure column
# was blank on every mild wall panel. `capacity.flexure_at` dispatches:
# a piece carrying mild steel goes to `mild_flexure_at` (Whitney block,
# mild As on the P-M surface, strain-based phi with the 0.75 floor --
# docs sec.4), a pure-strand or bare piece stays on `handling_flexure_
# at` byte-for-byte. Handling is a flat self-weight lift, so pu_lb=0.
phi_r, phi_mn_r, flex_ratio = capacity.flexure_at(
panel, stage, x, m_lbft, cap=cap)
rows.append({'x': x, 'm': m_lbft, 'v': v_lb, 'cap': cap,
'shear': shear_pt,
'phi_r': phi_r, 'phi_mn_r': phi_mn_r,
'flex_ratio': flex_ratio, 'stress': None})
# FIX 1: the extreme-fibre service stress check.
sp = capacity.handling_stress_at(panel, stage, x, m_lbft)
rows[-1]['stress'] = sp
if flex_ratio is not None:
cands.append(('flexure M/&phi;M<sub>n</sub>', flex_ratio, x))
if sp.tension_util is not None and sp.tension_psi > 0.0:
cands.append(('tension stress', sp.tension_util, x))
if sp.compression_util is not None and sp.compression_psi > 0.0:
cands.append(('compression stress', sp.compression_util, x))
shear_gov_x, shear_gov_pt = _dense_shear_governing(analysis, panel, stage)
if shear_gov_pt is not None:
cands.append((
'shear V/&phi;V<sub>n</sub> ({0} governs)'.format(
R.esc(shear_gov_pt.governed_by or '?')),
shear_gov_pt.v_over_phi_vn, shear_gov_x))
gov = max(cands, key=lambda c: c[1]) if cands else None
return rows, gov
def handling_capacity_group(analysis, panel, stage, xs):
"""One demand-vs-capacity + stress table for a single stage.
The demand is the SOLVED self-weight diagram -- M(x), V(x) at each station,
the analysis's own factored handling numbers -- and the flexure capacity is
`capacity.capacity_at` fed that real moment, so the tension face is the
real one. SHEAR is the DETAILED Vci/Vcw method instead
(`capacity.detailed_shear_at`, FIX 6/parity gap #6) -- `capacity_at`'s own
near-constant simplified V_c = 2*sqrt(f'c)*bw*d stays exactly what Panel
Lift Check consumes and is not shown here. On top of the strength columns
(M_cr, phi*Vn) this carries the strain-based phi and its ratio (FIX 3, ACI
318-14 Table 21.2.2) and the extreme-fibre STRESS check with its
tension/compression utilisation (FIX 1). A station that cannot be judged
is a dash with its reason, never a printed zero.
"""
rows, gov = _stage_station_results(analysis, panel, stage, xs)
out = []
if gov is not None:
out.append(
'<p class="fieldnote"><b>Governing check (this stage):</b> '
'<b>{0}</b> at {1}, {2}.</p>'.format(
gov[0], R.fmt_ft_in(gov[2]), _ratio_cell(gov[1], None)))
hdr = ('<tr><th>Loc (ft)</th><th>M (k-ft)</th><th>V (k)</th>'
'<th>&phi;</th><th>&phi;M<sub>n</sub> (k-ft)</th>'
'<th>M<sub>cr</sub> (k-ft)</th>'
'<th>&phi;V<sub>n</sub> (k)</th>'
'<th>f<sub>se</sub> (ksi)</th>'
'<th>M/&phi;M<sub>n</sub></th><th>V/&phi;V<sub>n</sub></th>'
'<th>Shear governs</th></tr>')
crows = [hdr]
for r in rows:
cap = r['cap']
shear = r['shear']
crows.append(
'<tr><td>{0}</td><td>{1}</td><td>{2}</td>'
'<td>{3}</td><td>{4}</td><td>{5}</td><td>{6}</td><td>{7}</td>'
'<td>{8}</td><td>{9}</td><td>{10}</td></tr>'.format(
R.fmt_ft_in(r['x']),
R.n(r['m'] / 1000.0, 2), R.n(r['v'] / 1000.0, 2),
_cell(r['phi_r'], 3, cap.reason(capacity.PHI_MN)),
_cell(r['phi_mn_r'] and r['phi_mn_r'] / 1000.0, 2,
cap.reason(capacity.PHI_MN)),
_cell(cap.mcr_lbft and cap.mcr_lbft / 1000.0, 2,
cap.reason(capacity.MCR)),
_cell(shear.phi_vn_lb and shear.phi_vn_lb / 1000.0, 1,
shear.why),
_cell(cap.fse_ksi, 1, cap.reason(capacity.FSE)),
_ratio_cell(r['flex_ratio'],
cap.reason(capacity.M_OVER_PHI_MN)),
_ratio_cell(shear.v_over_phi_vn, shear.why),
_text_cell(shear.governed_by, shear.why)))
out.append('<h5>Flexure &amp; shear (&phi; from the net tensile strain, '
'0.65&ndash;0.90, ACI 318-14 Table 21.2.2; shear is the '
'detailed Vci/Vcw method)</h5>')
out.append('<p class="fieldnote">The <b>governing</b> shear station above '
'is taken from a scan of every diagram station, not only the '
'rows printed here -- the detailed method\'s peak can sit off '
'this L/20 grid (see the governing-check line).</p>')
out.append('<div class="tablewrap">' + table(crows, 'stations') + '</div>')
allow_t = stage.allowable_tension_psi
allow_c = stage.allowable_compression_psi
out.append('<h5>Extreme-fibre stress (service): '
'f<sub>t</sub> vs {0} psi ({1}), '
'f<sub>c</sub> vs {2} psi ({3})</h5>'.format(
R.n(allow_t, 0), R.esc(stage.tension_basis),
R.n(allow_c, 0), R.esc(stage.compression_basis)))
srows = ['<tr><th>Loc (ft)</th>'
'<th>f<sub>form</sub> (psi)</th><th>f<sub>far</sub> (psi)</th>'
'<th>f<sub>t</sub> (psi)</th><th>f<sub>t</sub>/allow</th>'
'<th>f<sub>c</sub> (psi)</th><th>f<sub>c</sub>/allow</th></tr>']
for r in rows:
sp = r['stress']
srows.append(
'<tr><td>{0}</td><td>{1}</td><td>{2}</td>'
'<td>{3}</td><td>{4}</td><td>{5}</td><td>{6}</td></tr>'.format(
R.fmt_ft_in(r['x']),
_cell(sp.f_form_psi, 0, sp.why),
_cell(sp.f_far_psi, 0, sp.why),
_cell(sp.tension_psi, 0, sp.why),
_ratio_cell(sp.tension_util, sp.why),
_cell(sp.compression_psi, 0, sp.why),
_ratio_cell(sp.compression_util, sp.why)))
out.append('<div class="tablewrap">' + table(srows, 'stations') + '</div>')
return ''.join(out), gov
def handling_stage_sections(deck, panel):
"""One section per handling stage: reactions, its OWN shear/moment
diagrams, and its demand-vs-capacity + stress tables.
Returns a list of dicts, one per stage (default Stripping x1.4 at f'ci,
Transportation x1.5 at f'c, Erection x1.2 at f'c), each carrying
`{'title', 'tag', 'body', 'verdict', 'gov'}` for `build_analysis_page`'s
Summary card and section list -- `gov` is `(check_html, util, x)` or
None; `verdict` is `_verdict(gov[1])`.
Every stage shares the SAME `structural_model.supports`, so if the beam
solve refuses (fewer than two of them) it refuses identically for all
three -- there is one NOT-JUDGED entry in that case, not three, and it
carries the engine's own refusal sentence, never a fabricated result.
Previously this page solved and diagrammed only ONE generic stage
(`schema.handling_stage`, multiplier from `loading.self_weight.
load_factor`) and ran the three REAL named stages for their
capacity/stress tables alone -- so the free body and the shear/moment
diagrams shown never actually matched the magnitudes any real stage's
governing ratio was measured against. Diagramming each named stage's own
solve instead removes that generic stage and the mismatch with it.
"""
stages = schema.handling_stages(deck, panel)
if not stages:
stages = [schema.handling_stage(deck, panel)]
probe, probe_ref = solve_stage(panel, stages[0])
if probe is None:
return [{
'title': 'Handling', 'verdict': None, 'gov': None,
'tag': _notjudged_tag(),
'body': ('<div class="notjudged-box"><b>NOT JUDGED.</b> The '
'self-weight Handling model was not solved: {0}. This '
'is the engine\'s own refusal, surfaced rather than '
'swallowed &mdash; a beam that cannot be supported must '
'not analyse as fine.</div>'.format(
R.notate(probe_ref or 'no reason given'))),
}]
xs = key_stations(panel)
out = []
for i, stage in enumerate(stages):
an = probe if i == 0 else solve_stage(panel, stage)[0]
title = 'Handling — {0}'.format(stage.name)
strength_label = ('ci' if (panel.fci_psi and
stage.fc_psi == panel.fci_psi) else 'c')
body = ['<p class="fieldnote">&times;{0} at f&prime;<sub>{1}</sub> '
'= {2} psi. The panel as a beam spanning its '
'structural_model.supports, carrying its own factored self '
'weight -- solved by <code>panellift.statics</code> '
'(continuous beam on rigid supports); the capacity columns '
'are <code>panellift.capacity</code>, strand only.</p>'
.format(R.n(stage.multiplier, 2), strength_label,
R.n(stage.fc_psi))]
body.append('<figure>{0}</figure>'.format(R.free_body(an)))
body.append('<h3>Support reactions</h3>')
body.append(handling_reactions_table(an, deck))
shear = [v / R.LB_PER_KIP for v in an.diagram.shear_lb]
moment = [m / R.LB_PER_KIP for m in an.diagram.moment_lbft]
body.append('<h3>Shear and moment</h3>')
body.append('<figure>{0}</figure>'.format(
R.diagram_plot(an, shear, 'Shear V(x)', 'kip',
digits=2, symbol='V')))
body.append('<figure>{0}</figure>'.format(
R.diagram_plot(an, moment, 'Moment M(x)', 'kip-ft',
digits=1, symbol='M')))
body.append('<h3>Demand vs capacity, per station</h3>')
body.append('<p class="fieldnote">L/20 stations plus every '
'opening-edge discontinuity. M and V are the solved '
'self-weight demand; &phi;M<sub>n</sub>, M<sub>cr</sub>, '
'&phi;V<sub>n</sub> and f<sub>se</sub> are strand-only '
'capacity, and the extreme-fibre stress is the service '
'prestress+self-weight stress. A ratio over 1.0 is a '
'FAIL the tool may not bless (capacity.py\'s ruling); a '
'station it could not judge is a dash with its reason, '
'never a zero.</p>')
html, gov = handling_capacity_group(an, panel, stage, xs)
body.append(html)
verdict = _verdict(gov[1]) if gov is not None else None
tag = (_sec_tag(gov[0], gov[1], gov[2], verdict) if gov is not None
else _notjudged_tag())
detail = ('{0} at {1}'.format(gov[0], R.fmt_ft_in(gov[2]))
if gov is not None else
'every station dashed with its reason')
out.append({'title': title, 'verdict': verdict, 'gov': gov,
'tag': tag, 'detail': detail, 'body': ''.join(body)})
return out
def sec_analysis_general(deck, panel):
# Read-only always: only `build_analysis_page` (the results page) calls
# this -- the OLD demo form has its own `sec_general`, above.
pi = deck.get('project_information') or {}
rows = [
field_row('Mark', panel.mark, 'deck', readonly=True),
field_row('Design number', pi.get('design_number')
or panel.design_number or '', 'deck', readonly=True),
field_row("f'c (psi)", panel.fc_psi, 'deck', readonly=True),
field_row("f'ci (psi)", panel.fci_psi, 'deck', readonly=True),
field_row('Prestress loss (%)', pi.get('prestress_loss_pct'), 'deck',
readonly=True),
]
src = deck.get('source') or {}
if src.get('synthetic'):
rows.append(note_row('Source', src.get('note')
or 'synthetic Stage 0 fixture'))
return table(rows)
def sec_analysis_structural_model(deck):
"""Read-only always -- see `sec_analysis_general`."""
body = []
sm = deck.get('structural_model') or {}
body.append('<h3>Supports (the Handling beam spans these) '
'<span class="prov">from model</span></h3>')
scells = ['<tr><th>#</th><th>x (ft)</th><th>Type</th></tr>']
for i, s in enumerate(sm.get('supports') or [], 1):
scells.append('<tr><td>{0}</td><td>{1}</td><td>{2}</td></tr>'.format(
i, finput(_ft_val(s.get('x_ft')), 'deck', width=6, readonly=True,
word=False),
finput(s.get('type') or 'support', 'deck', width=10,
readonly=True, word=False)))
body.append(table(scells, 'cells'))
rows = schema.restraint_rows(deck)
if rows:
body.append('<h3>Restraints (the In-Place panel spans these) '
'<span class="prov">from model</span></h3>')
rcells = ['<tr><th>#</th><th>x (ft)</th><th>Type</th>'
'<th>Gravity</th><th>Thermal</th><th>Lateral?</th></tr>']
any_fixed = False
for i, r in enumerate(rows, 1):
if str(r.get('type') or '').strip().lower() == 'fixed':
any_fixed = True
rcells.append(
'<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td>'
'<td>{4}</td><td>{5}</td></tr>'.format(
i, finput(_ft_val(r.get('x_ft')), 'deck', width=6,
readonly=True, word=False),
finput(r.get('type'), 'deck', width=10, readonly=True,
word=False),
finput(r.get('gravity'), 'deck', width=8, readonly=True,
word=False),
finput(r.get('thermal'), 'deck', width=8, readonly=True,
word=False),
'yes' if r.get('lateral') else
'<span class="fieldnote">no -- Free</span>'))
body.append(table(rcells, 'cells'))
if any_fixed:
body.append('<p class="fieldnote">A \'Fixed\' restraint is '
'modelled as a PIN for now &mdash; the in-place '
'solve does not yet carry true rotational fixity.'
'</p>')
else:
blk = sm.get('restraints')
if isinstance(blk, dict) and 'reason' in blk and blk.get('value') is None:
body.append('<p class="fieldnote"><b>Restraints:</b> {0}</p>'
.format(R.notate(blk['reason'])))
bblk = sm.get('bearing')
if isinstance(bblk, dict) and 'reason' in bblk and bblk.get('value') is None:
body.append('<p class="fieldnote"><b>Bearing:</b> {0}</p>'.format(
R.notate(bblk['reason'])))
return ''.join(body)
def sec_analysis_settings(deck):
"""Read-only always -- see `sec_analysis_general`."""
st = deck.get('settings') or {}
rows = [
field_row("Handling tensile limit, x*sqrt(f'c)",
st.get('tension_limit_sqrt_fc'), 'deck', readonly=True),
field_row("Handling compression limit ratio, x*f'c",
st.get('compression_limit_ratio'), 'deck', readonly=True),
field_row('phi, flexure (tension-controlled)',
st.get('phi_flexure'), 'deck', readonly=True),
field_row('phi, shear', st.get('phi_shear'), 'deck', readonly=True),
field_row('Development length multiplier (strand)',
st.get('development_length_multiplier'), 'deck',
readonly=True),
]
seed = st.get('_seeded_from')
if isinstance(seed, dict) and seed.get('reason'):
rows.append(note_row('Provenance', seed['reason']))
return table(rows)
IN_PLACE_PLACEHOLDER = (
'<div class="placeholder-box">Not computed &mdash; the In-Place engine '
'(panel on pin supports under lateral + gravity, ACI 318 load '
'combinations) is a later stage (Stage 3). This is an explicit '
'placeholder, never a fabricated zero.</div>')
def _in_place_unwired(reason):
return ('<div class="placeholder-box">Not computed &mdash; {0}. The '
'In-Place model (panel spanning its lateral restraints under '
'wind + gravity, ACI 318 / ASCE 7 strength combinations) needs '
'those inputs on the deck; until they are wired this stays an '
'explicit placeholder, never a fabricated zero.</div>'.format(
R.notate(reason or 'in-place inputs not supplied')))
def solve_in_place(deck, panel):
"""(result, reason) -- the in-place strength run, or None+reason.
None+reason where the deck has not wired the in-place inputs (the Stage-0
default), so the page keeps the honest placeholder. A wired deck that the
engine refuses (fewer than two restraints) comes back as a `result` whose
own `refusal` is set, surfaced as NOT JUDGED rather than swallowed.
"""
try:
inputs, reason = schema.in_place_inputs(deck, panel)
except (schema.DeckError, ValueError) as exc:
return None, str(exc)
if inputs is None:
return None, reason
return inplace.run(panel, inputs), None
def _inplace_gov_banner(result):
if result.gov_util is None:
return ('<p class="fieldnote">No in-place strength check could be '
'judged &mdash; every station dashed with its reason.</p>')
return ('<div class="demo-banner"><b>Governing in-place strength check:</b> '
'{0} &mdash; {1} at {2}, {3}.</div>'.format(
R.esc(result.gov_combo), R.esc(result.gov_check),
R.fmt_ft_in(result.gov_x or 0.0),
_ratio_cell(result.gov_util, None)))
def sec_wind_zones(deck):
"""`loading.wind`'s mode + its zones table, AS ENTERED.
Point 4 of the manual per-zone wind contract (PANELANALYSIS-4): the page
prints the zones exactly as the deck carries them -- label, from/to,
width, pressure, suction -- and the mode, rather than only the engine's
single post-processed governing pressure. A `project_config` deck (not
built) gets its own explicit placeholder, never a fabricated table; a
deck with no `zones` list at all (the ASCE-computed / direct-entry path)
points at the section above instead of printing an empty table.
"""
blk = (deck.get('loading') or {}).get('wind')
if blk is None:
return '<p class="fieldnote">No <code>loading.wind</code> block on '\
'this deck.</p>'
if isinstance(blk, dict) and 'reason' in blk and blk.get('value') is None:
return '<p class="fieldnote">{0}</p>'.format(R.notate(blk['reason']))
if not isinstance(blk, dict):
return '<p class="fieldnote">Malformed <code>loading.wind</code> '\
'block: {0!r}</p>'.format(blk)
mode = blk.get('mode')
body = ['<p class="fieldnote"><b>Mode:</b> {0}</p>'.format(
R.esc(mode or 'legacy (given zones, or ASCE / direct-entry -- see '
'below)'))]
if mode == 'project_config':
body.append('<div class="placeholder-box">Not computed &mdash; '
'{0}.</div>'.format(R.esc(schema.WIND_PROJECT_CONFIG_NOT_BUILT)))
return ''.join(body)
zdefs = blk.get('zones')
if not isinstance(zdefs, list):
body.append('<p class="fieldnote">No explicit zones on this deck '
'&mdash; the out-of-plane pressure comes from the '
'ASCE / direct-entry section above (one governing '
'zone over the panel length).</p>')
return ''.join(body)
rows = ['<tr><th>Label</th><th>From (ft)</th><th>To (ft)</th>'
'<th>Width (ft)</th><th>Pressure (psf)</th>'
'<th>Suction (psf)</th></tr>']
for i, z in enumerate(zdefs):
if not isinstance(z, dict):
continue
rows.append(
'<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td>'
'<td>{4}</td><td>{5}</td></tr>'.format(
finput(z.get('label') or 'W{0}'.format(i + 1), 'deck',
width=8, readonly=True, word=False),
finput(_ft_val(z.get('x0_ft')), 'deck', width=6,
readonly=True, word=False),
finput(_ft_val(z.get('x1_ft')), 'deck', width=6,
readonly=True, word=False),
finput(_ft_val(z.get('width_ft')), 'deck', width=6,
readonly=True, word=False),
finput(z.get('pressure_psf'), 'deck', width=6, readonly=True,
word=False),
finput(z.get('suction_psf'), 'deck', width=6, readonly=True,
word=False)))
body.append('<p class="fieldnote"><span class="prov">from model</span> '
'&mdash; every column above.</p>')
body.append(table(rows, 'cells'))
if mode == 'manual':
body.append('<p class="fieldnote">Manual mode: pressure and '
'suction are read independently per zone &mdash; a '
'blank cell means that direction is simply absent for '
'that zone, never assumed equal to the other. A blank '
'width uses the panel\'s own tributary width.</p>')
return ''.join(body)
def sec_in_place_wind(deck, panel):
"""ASCE C&C wind: the inputs, the computed pressure (pre-filled), the GCpi.
This is where the ASCE engine is wired in. The form COLLECTS the ASCE inputs
(Vult, exposure, enclosure, version, mean roof height, effective wind area)
and any direct-entry pressure; this CPython step runs `windload.compute` on
them and PRE-FILLS the resulting C&C wall pressure into the wind-pressure
field -- with its +/- figure-read uncertainty and its cite -- exactly the
pre-fill-vs-blank pattern the rest of the page uses. The user can still
override with a direct pressure (direct > computed > blank). Where windload
returns NOT JUDGED (h>60 ft, an un-mined ASCE version, bad inputs), the
reason is shown and no pressure is fabricated.
"""
blk = (deck.get('loading') or {}).get('wind')
# The explicit given-pressure path (goldens/fixtures, and the manual
# per-zone contract -- both carry a `zones` key), `project_config` (not
# built, own section below) and the unwired placeholder all fall through
# to the load-case section's own handling.
if not isinstance(blk, dict) or 'zones' in blk or (
'reason' in blk and blk.get('value') is None) or (
blk.get('mode') == 'project_config'):
return None
info = schema.resolve_wind(deck)
if info is None:
return None
body = []
a = blk.get('asce') or {}
if a:
rows = [
field_row('Design wind speed Vult (mph)', a.get('v_ult_mph'),
'deck', readonly=True),
field_row('Risk Category', a.get('risk_category'), 'deck',
readonly=True),
field_row('Exposure Category', a.get('exposure'), 'deck',
readonly=True),
field_row('Enclosure Classification', a.get('enclosure'), 'deck',
readonly=True),
field_row('ASCE code version', a.get('code_version'), 'deck',
readonly=True),
field_row('C&C chapter', a.get('cc_chapter'), 'deck',
readonly=True),
field_row('Mean roof height h (ft)',
_ft_val(a.get('mean_roof_height_ft')), 'deck',
readonly=True),
field_row('Effective wind area (ft^2)',
a.get('effective_wind_area_ft2'), 'deck',
readonly=True),
]
# Enclosure -> GCpi (the owner's explicit requirement).
if info.get('gcpi') is not None:
rows.append(field_row('Internal pressure coefficient GCpi',
'+/-{0}'.format(R.n(info['gcpi'], 2)),
'computed', info.get('gcpi_cite'),
readonly=True))
body.append('<h3>ASCE inputs</h3>')
body.append(table(rows))
elif blk.get('asce_incomplete'):
body.append('<p class="fieldnote">{0}</p>'.format(
R.notate(blk['asce_incomplete'])))
res = info.get('result')
if res is not None and not res.not_judged and res.zones:
body.append('<h3>Computed C&amp;C wall pressures</h3>')
body.append('<p class="fieldnote">From <code>panellift.windload</code> '
'(ASCE 7 Ch.30 Part 1, h&le;60 ft). The wall (GCp) are '
'figure-read, so every pressure carries &plusmn;{0} psf. '
'{1}</p>'.format(
R.n(info.get('uncertainty_psf') or 0.0, 2),
R.esc(info.get('cite') or '')))
wrows = ['<tr><th>Zone</th><th>Positive p (psf)</th>'
'<th>Negative p (psf)</th><th>&plusmn; (psf)</th></tr>']
for z in res.zones:
wrows.append('<tr><td>{0}</td><td>{1}</td><td>{2}</td>'
'<td>{3}</td></tr>'.format(
z.zone, R.n(z.positive_psf, 1),
R.n(z.negative_psf, 1),
R.n(z.uncertainty_psf, 2)))
body.append('<div class="tablewrap">' + table(wrows, 'cells') + '</div>')
elif res is not None and res.not_judged:
body.append('<div class="notjudged-box"><b>NOT JUDGED.</b> {0}</div>'
.format(R.notate(res.not_judged)))
# The pressure the in-place wind case runs on, pre-filled, with its source.
src = info.get('source')
pressure = info.get('pressure_psf')
prow_source = 'computed' if src == 'computed' else (
'deck' if src == 'direct' else 'placeholder')
detail = {
'computed': 'windload C&C governing pressure; ' + (info.get('cite') or ''),
'direct': 'your direct entry -- overrides the computed pressure',
'not_judged': info.get('reason') or '',
'blank': info.get('reason') or '',
}.get(src, '')
prows = [
note_row('Pressure used by the in-place wind case',
{'direct': 'direct entry (overrides the computed value)',
'computed': 'windload C&C computed pressure',
'not_judged': 'NONE -- windload returned NOT JUDGED',
'blank': 'NONE -- no direct entry and no computable ASCE '
'inputs'}.get(src, src)),
'<tr><th>Governing out-of-plane pressure (psf)</th><td>{0}</td></tr>'
.format(finput(None if pressure is None else R.n(pressure, 1),
prow_source, detail, readonly=True)),
]
if info.get('direct_pressure_psf') is not None or \
info.get('direct_suction_psf') is not None:
prows.append(note_row(
'Direct entry',
'pressure {0} psf / suction {1} psf'.format(
fmt(info.get('direct_pressure_psf'), 1) or '-',
fmt(info.get('direct_suction_psf'), 1) or '-')))
if src in ('not_judged', 'blank') and info.get('reason'):
prows.append(note_row('Why no pressure', info['reason']))
body.append('<h3>Precedence: direct &gt; computed &gt; blank</h3>')
body.append(table(prows))
return ''.join(body)
def sec_in_place_load_cases(result, reason):
"""Load Case Results: the elementary D / W (and companion) fields."""
if result is None:
return _in_place_unwired(reason)
if result.refusal:
return ('<div class="notjudged-box"><b>NOT JUDGED.</b> {0}</div>'
.format(R.notate(result.refusal)))
body = ['<p class="fieldnote">The elementary load cases on the in-place '
'restraints (ASCE 7-16 Ch.2 / docs sec.7). Dead is the gravity '
'axial (self weight above the station + bearing dead); Wind is the '
'out-of-plane moment/shear from the continuous-beam solve of the '
'GIVEN pressure (not computed from ASCE Vult here). Peak values '
'over the piece:</p>']
rows = ['<tr><th>Case</th><th>Symbol</th><th>Built?</th>'
'<th>Peak axial (k)</th><th>Peak |M| (k-ft)</th>'
'<th>Peak |V| (k)</th></tr>']
for sym in ('D', 'L', 'W', 'R', 'E', 'T', 'H'):
c = result.cases.get(sym)
if c is None:
continue
pk_p = max((abs(v) for v in c.axial_lb), default=0.0) / 1000.0
pk_m = max((abs(v) for v in c.moment_lbft), default=0.0) / 1000.0
pk_v = max((abs(v) for v in c.shear_lb), default=0.0) / 1000.0
rows.append('<tr><td>{0}</td><td>{1}</td><td>{2}</td>'
'<td>{3}</td><td>{4}</td><td>{5}</td></tr>'.format(
R.esc(c.name), sym,
'yes' if c.built else R.esc('no -- ' + (c.note or '')),
R.n(pk_p, 1), R.n(pk_m, 1), R.n(pk_v, 1)))
body.append(table(rows, 'cells'))
if result.notes:
for n in result.notes:
body.append('<p class="fieldnote">{0}</p>'.format(R.notate(n)))
return ''.join(body)
def sec_in_place_combinations(result, reason):
"""Load Combination Results: factored demand + governing util per combo."""
if result is None:
return _in_place_unwired(reason)
if result.refusal:
return ('<div class="notjudged-box"><b>NOT JUDGED.</b> {0}</div>'
.format(R.notate(result.refusal)))
body = [_inplace_gov_banner(result)]
body.append('<p class="fieldnote">The ACI 318-14 / ASCE 7-16 sec.2.3 '
'STRENGTH combinations (docs sec.7), each assembled by '
'superposing the load-case fields. Service combinations are a '
'later slice. A combo carrying 1.0T / 1.6H or seismic E that '
'this slice does not model is flagged; where the job has no '
'such load the term is 0.</p>')
rows = ['<tr><th>LC</th><th>Combination</th><th>Peak P<sub>u</sub> (k)</th>'
'<th>Peak |M<sub>u</sub>| (k-ft)</th><th>Peak |V<sub>u</sub>| (k)'
'</th><th>Governing</th><th>Util</th></tr>']
for cr in result.combos:
gov = ('{0} @ {1}'.format(cr.gov_check, R.fmt_ft_in(cr.gov_x))
if cr.gov_check else '&mdash;')
flag = ' *' if cr.combo.has_unbuilt else ''
rows.append('<tr><td>{0}{1}</td><td>{2}</td><td>{3}</td><td>{4}</td>'
'<td>{5}</td><td>{6}</td><td>{7}</td></tr>'.format(
R.esc(cr.combo.name), flag, R.esc(cr.combo.label),
R.n(cr.max_pu_lb / 1000.0, 1),
R.n(cr.max_mu_lbft / 1000.0, 1),
R.n(cr.max_vu_lb / 1000.0, 1), gov,
_ratio_cell(cr.gov_util, None)))
body.append('<div class="tablewrap">' + table(rows, 'stations') + '</div>')
body.append('<p class="fieldnote">* combination carries a 1.0T / 1.6H or '
'seismic-E term this slice does not model.</p>')
return ''.join(body)
def _interp(xs, ys, x):
"""Linear interpolation of `ys` over `xs` (both sorted, same length) --
the SAME piecewise-linear reading `inplace.LoadField.moment_at` gives the
engine itself between its own `key_stations` nodes (see that function's
docstring on why the grid is forced onto every real jump already), used
here only to place the governing-station dot at the right height on a
curve drawn through those exact samples -- never to invent a value
between them.
"""
if x <= xs[0]:
return ys[0]
if x >= xs[-1]:
return ys[-1]
for i in range(1, len(xs)):
if xs[i] >= x:
x0, x1, y0, y1 = xs[i - 1], xs[i], ys[i - 1], ys[i]
if x1 == x0:
return y0
return y0 + (x - x0) / (x1 - x0) * (y1 - y0)
return ys[-1]
def _inplace_diagram(panel, xs, values, restraint_xs, gov_x, title, unit,
digits, symbol):
"""One in-place M(x)/V(x) diagram for the governing combination, drawn
with `panellift.report`'s own `Plot` canvas (imported, not copied) from
`xs`/`values` -- the engine's own `key_stations` samples (L/20 plus every
discontinuity; see `inplace.key_stations`'s docstring), the same grid the
per-station table beside it prints, joined by straight segments because
that IS the piecewise-linear field the engine reads between them, not a
smoothed approximation of a finer curve nobody solved.
`R.Plot.axis_x()` now draws feet-inches ticks itself (2026-09 fix,
prompted by this function's own first draft doing it by hand because
`report.py` was not yet this file's to change) -- reused directly, same
as `R.diagram_plot` (the handling stages' reused engine diagram) already
does. `gov_x` (or None) draws the governing station as a highlighted
dot, same red `.worst` dot `stress_plot` already uses for the same
purpose.
"""
pts = list(zip(xs, values))
lo, hi = min(values), max(values)
pad = (hi - lo) * 0.15 or 1.0
p = R.Plot()
p.domain(0, panel.length_ft, lo - pad, hi + pad)
p.axis_x()
p.area(pts, 0.0, 'fill')
p.path(pts, 'series')
p.hline(0.0, 'zero')
p.hline(hi, 'rule', '{0} {1}'.format(R.n(hi, digits), unit))
if lo < 0:
p.hline(lo, 'rule', '{0} {1}'.format(R.n(lo, digits), unit))
for rx in restraint_xs:
p.vline(rx, 'pick')
if gov_x is not None:
gv = _interp(xs, values, gov_x)
p.add('<circle class="worst" cx="{0:.2f}" cy="{1:.2f}" r="4"/>'
.format(p.px(gov_x), p.py(gv)))
p.hover(pts, symbol, unit, digits)
return p.render(title)
def sec_in_place_strength(result, reason):
"""In-Place Strength Summary: flexure / shear / axial under the gov combo."""
if result is None:
return _in_place_unwired(reason)
if result.refusal:
return ('<div class="notjudged-box"><b>NOT JUDGED.</b> {0}</div>'
.format(R.notate(result.refusal)))
if not result.combos:
return ('<div class="notjudged-box"><b>NOT JUDGED.</b> No strength '
'combinations were assembled.</div>')
# The controlling combination -- the one that carries the overall governing
# utilisation. Its per-station table is what Eriksson prints.
cr = next((c for c in result.combos if c.combo.name == result.gov_combo),
result.combos[0])
body = [_inplace_gov_banner(result)]
body.append('<p class="fieldnote">Per-station strength under the governing '
'combination <b>{0}</b> ({1}). Flexure &phi;M<sub>n</sub> and '
'shear &phi;V<sub>n</sub> REUSE <code>panellift.capacity</code> '
'(strand Whitney block; detailed Vci/Vcw); axial &Phi;P<sub>n'
'</sub>(C) and &Phi;P<sub>crit</sub> are ACI 318-14 22.4.2.3 / '
'docs sec.5. A station that cannot be judged is a dash with its '
'reason &mdash; a mild-only wall dashes &phi;M<sub>n</sub> '
'(capacity is strand-only, the concurrent capacity layer\'s '
'gap), never a fabricated pass.</p>'.format(
R.esc(cr.combo.name), R.esc(cr.combo.label)))
xs = [sd.x for sd in cr.stations]
if len(xs) >= 2:
moments = [sd.mu_lbft / 1000.0 for sd in cr.stations]
shears = [sd.vu_lb / 1000.0 for sd in cr.stations]
restraint_xs = result.inputs.restraint_xs
gov_m_x = cr.gov_x if cr.gov_check == 'flexure' else None
gov_v_x = cr.gov_x if cr.gov_check == 'shear' else None
body.append('<h3>Shear and moment (governing combination, '
'envelope)</h3>')
body.append('<p class="fieldnote">Drawn through the same '
'L/20-plus-discontinuity stations the table below '
'prints, under the SAME governing combination -- not a '
'finer solve. The highlighted station is the one named '
'in the governing-check line above.</p>')
body.append('<figure>{0}</figure>'.format(
_inplace_diagram(result.panel, xs, shears, restraint_xs,
gov_v_x, 'Shear Vu(x) -- {0}'.format(
cr.combo.name), 'kip', 2, 'V')))
body.append('<figure>{0}</figure>'.format(
_inplace_diagram(result.panel, xs, moments, restraint_xs,
gov_m_x, 'Moment Mu(x) -- {0}'.format(
cr.combo.name), 'kip-ft', 1, 'M')))
hdr = ('<tr><th>Loc (ft)</th><th>M<sub>u</sub> (k-ft)</th>'
'<th>&phi;M<sub>n</sub> (k-ft)</th><th>M<sub>cr</sub> (k-ft)</th>'
'<th>M/&phi;M<sub>n</sub></th>'
'<th>V<sub>u</sub> (k)</th><th>&phi;V<sub>n</sub> (k)</th>'
'<th>V/&phi;V<sub>n</sub></th>'
'<th>P<sub>u</sub> (k)</th><th>&Phi;P<sub>n</sub>(C) (k)</th>'
'<th>&Phi;P<sub>crit</sub> (k)</th><th>P/&Phi;P<sub>n</sub></th></tr>')
rows = [hdr]
for sd in cr.stations:
cap = sd.cap
sh = sd.shear
ax = sd.axial
rows.append(
'<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td><td>{4}</td>'
'<td>{5}</td><td>{6}</td><td>{7}</td>'
'<td>{8}</td><td>{9}</td><td>{10}</td><td>{11}</td></tr>'.format(
R.fmt_ft_in(sd.x),
R.n(sd.mu_lbft / 1000.0, 1),
_cell(cap.phi_mn_lbft and cap.phi_mn_lbft / 1000.0, 1,
cap.reason(capacity.PHI_MN)),
_cell(cap.mcr_lbft and cap.mcr_lbft / 1000.0, 1,
cap.reason(capacity.MCR)),
_ratio_cell(sd.flex_util, cap.reason(capacity.M_OVER_PHI_MN)),
R.n(sd.vu_lb / 1000.0, 1),
_cell(sh.phi_vn_lb and sh.phi_vn_lb / 1000.0, 1, sh.why),
_ratio_cell(sd.shear_util, sh.why),
R.n(sd.pu_lb / 1000.0, 1),
_cell(ax.phi_pn_c_lb and ax.phi_pn_c_lb / 1000.0, 1, ax.why),
_cell(ax.phi_pcrit_lb and ax.phi_pcrit_lb / 1000.0, 1,
ax.pcrit_note),
_ratio_cell(sd.axial_util, ax.why)))
body.append('<div class="tablewrap">' + table(rows, 'stations') + '</div>')
body.append(_pdelta_note(cr))
body.append('<p class="fieldnote"><b>Stub, this slice:</b> thermal (T) and '
'soil (H) load cases, the seismic (E) case, live-load '
'patterning, and the ASCE wind computation (Vult / exposure / '
'GCp &rarr; the pressure, which is a GIVEN input here). Flexure, '
'shear, axial, the SERVICE checks (below) and the P-Delta '
'magnifier are built.</p>')
return ''.join(body)
def _pdelta_note(cr):
"""The 2nd-order (P-Delta) magnifier for a combo, docs sec.5.3."""
pd = getattr(cr, 'pdelta', None)
if pd is None:
return ''
if pd.magnifier is None:
return ('<p class="fieldnote"><b>P-&Delta; (2nd-order, docs sec.5.3):'
'</b> {0}</p>'.format(R.notate(pd.why or 'not computed')))
return ('<p class="fieldnote"><b>P-&Delta; (2nd-order, docs sec.5.3):</b> '
'moment magnifier &delta;<sub>ns</sub> = {0} '
'(&gamma; = P<sub>u</sub>L&sup2;/8EI<sub>eff</sub> = {1}, '
'EI<sub>eff</sub> = 0.875&middot;E<sub>c</sub>I<sub>g</sub>, '
'&beta;<sub>d</sub>=0 short-term wind) over the {2} governing '
'span. Reported here; the reference tool applies it to the demand '
'after combinations (docs sec.11). At {3} it is essentially unity '
'on these braced walls.</p>'.format(
R.n(pd.magnifier, 3), R.n(pd.gamma or 0.0, 4),
R.fmt_ft_in(pd.span_ft),
'this magnitude' if pd.magnifier < 1.05 else 'this level'))
def _service_stress_table(stations, kind):
"""The tension or compression per-station summary, Eriksson's own columns."""
if kind == 'tension':
hdr = ('<tr><th>Loc (ft)</th><th>Tension (psi)</th>'
'<th>Cracking (psi)</th><th>Util</th><th>M (k-ft)</th>'
'<th>P (k)</th><th>Combo</th></tr>')
else:
hdr = ('<tr><th>Loc (ft)</th><th>Comp. (psi)</th><th>Limit (psi)</th>'
'<th>Util</th><th>M (k-ft)</th><th>P (k)</th><th>Combo</th></tr>')
rows = [hdr]
for st in stations:
if kind == 'tension':
val, lim, util = (st.tension_psi, st.cracking_limit_psi,
st.tension_util)
m, p, combo = st.m_tension_lbft, st.p_tension_lb, st.gov_tension_combo
else:
val, lim, util = (st.compression_psi, st.compression_limit_psi,
st.compression_util)
m, p, combo = st.m_comp_lbft, st.p_comp_lb, st.gov_comp_combo
rows.append('<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td>'
'<td>{4}</td><td>{5}</td><td>{6}</td></tr>'.format(
R.fmt_ft_in(st.x),
_cell(val, 1, st.why), _cell(lim, 1, st.why),
_ratio_cell(util, st.why),
R.n(m / 1000.0, 2), R.n(p / 1000.0, 1),
_text_cell(combo, st.why)))
return '<div class="tablewrap">' + table(rows, 'stations') + '</div>'
def _service_deflection_block(d):
"""The deflection breakdown (elastic + P-Delta) vs L/n, docs sec.6.3."""
if d is None:
return ''
rows = ['<tr><th>Term</th><th>Value</th></tr>']
# Deflection and camber are NOT dimensions of the piece -- they are
# computed results the engineer reads to three decimal places of an
# inch, same as Eriksson does, never a feet-inches fraction. The owner's
# feet-inches ruling is about a LENGTH someone measured or typed; a
# deflection is arithmetic output, so it stays decimal here on purpose.
def r(label, val, digits=3, unit=' in'):
rows.append('<tr><td>{0}</td><td>{1}</td></tr>'.format(
label, (R.n(val, digits) + unit) if val is not None else
'<span class="dash">&mdash;</span>'))
r('&Delta;<sub>wind</sub> (elastic, service wind)', d.delta_wind_in)
r('&Delta;<sub>bow</sub> (initial bow)', d.delta_bow_in)
r('&Delta;<sub>Pu</sub> (eccentric axial)', d.delta_pu_in)
r('&Delta;<sub>i</sub> (first-order sum)', d.delta_i_in)
r('&Delta;<sub>e</sub> (P-&Delta; increment)', d.delta_e_in)
r('&Delta;<sub>total</sub> = &Delta;<sub>i</sub>/(1&minus;&gamma;)',
d.delta_total_in)
r('P-&Delta; magnifier 1/(1&minus;&gamma;)', d.magnifier, digits=3, unit='')
r('limit L/{0}'.format(R.n(d.limit_ratio, 0)) if d.limit_ratio
else 'limit L/n', d.limit_in)
body = ['<p class="fieldnote">Deflection is elastic (double integration of '
'the governing service-wind moment field over the {0} span, '
'gross EI<sub>eff</sub>) plus the PCI 5.10.3.1 P-&Delta; iteration '
'(docs sec.6.3), against the per-job L/n limit.</p>'.format(
R.fmt_ft_in(d.span_ft))]
body.append(table(rows, 'kv'))
if d.util is not None:
body.append('<p class="fieldnote"><b>Deflection util:</b> {0}</p>'
.format(_ratio_cell(d.util, None)))
if d.why:
body.append('<div class="notjudged-box"><b>Deflection limit NOT '
'JUDGED.</b> {0}</div>'.format(R.notate(d.why)))
return ''.join(body)
def sec_in_place_service(result, reason):
"""In-Place Service Summary: tension/cracking, compression, deflection."""
if result is None:
return _in_place_unwired(reason)
if result.refusal:
return ('<div class="notjudged-box"><b>NOT JUDGED.</b> {0}</div>'
.format(R.notate(result.refusal)))
svc = getattr(result, 'service', None)
if svc is None:
return ('<div class="notjudged-box"><b>NOT JUDGED.</b> No service run '
'was assembled.</div>')
tension_lim = svc.stations[0].cracking_limit_psi if svc.stations else 0.0
banner = (
'<div class="demo-banner"><b>In-Place service (ASCE 7-16 sec.2.4 / '
'docs sec.6).</b> Extreme-fibre stress REUSES '
'<code>capacity.extreme_fibre_stresses</code> (P<sub>i</sub>/A '
'&minus;/+ (M+P<sub>i</sub>e)c/I) with the service axial layered on. '
'Worst tension {0} psi @ {1} (vs {2} psi cracking); worst '
'compression {3} psi @ {4} (vs 0.6 f&#39;c). '
'Tension util {5}; compression util {6}.</div>'.format(
R.n(svc.max_tension_psi, 1),
R.fmt_ft_in(svc.max_tension_x or 0.0), R.n(tension_lim, 1),
R.n(svc.max_compression_psi, 1),
R.fmt_ft_in(svc.max_compression_x or 0.0),
_ratio_cell(svc.tension_util, None),
_ratio_cell(svc.compression_util, None)))
body = [banner]
body.append('<p class="fieldnote">The ASCE 7-16 sec.2.4.1 ASD service '
'combinations (each carrying T at 0.75, sec.2.4.4; wind at '
'0.6). Tension and compression may govern under different '
'combos, so each station names its own.</p>')
combo_rows = ['<tr><th>SC</th><th>Combination</th><th>Cite</th></tr>']
for c in svc.combos:
combo_rows.append('<tr><td>{0}</td><td>{1}</td><td>{2}</td></tr>'.format(
R.esc(c.name), R.esc(c.label), R.esc(c.cite)))
body.append(table(combo_rows, 'cells'))
body.append('<h3>Tension vs cracking (docs sec.6.1)</h3>')
body.append(_service_stress_table(svc.stations, 'tension'))
body.append('<h3>Total compression vs 0.6 f&#39;c (docs sec.6.2)</h3>')
body.append(_service_stress_table(svc.stations, 'compression'))
body.append('<h3>Deflection (docs sec.6.3)</h3>')
body.append(_service_deflection_block(svc.deflection))
for n in svc.notes:
body.append('<p class="fieldnote">{0}</p>'.format(R.notate(n)))
return ''.join(body)
def sec_analysis_placeholders():
"""What the analysis page still does not compute (in-place strength IS built)."""
items = [
'Thermal (T) and soil (H) load cases -- Eriksson folds 1.0T/1.6H into '
'every combo; modelled as 0 here',
'Seismic (E) load case and its combinations',
'The ASCE wind engine (Vult / exposure / GCp / Kzt -> the pressure); '
'the wind pressure is a GIVEN input this slice',
'Live-load patterning across combinations',
'Horizontal shear / connector demand (also blocked on '
'source.connectors being unread)',
]
return '<ul>{0}</ul>'.format(
''.join('<li>{0}</li>'.format(R.notate(i)) for i in items))
def _join_and(items):
items = list(items)
if not items:
return ''
if len(items) == 1:
return items[0]
return '{0} and {1}'.format(', '.join(items[:-1]), items[-1])
def _scope_statement(solved, not_solved):
"""The accurate, per-deck replacement for the old stale banner -- the one
that always claimed "the In-Place model...is a later stage" even on a
deck (like this one) where it is already solved. Built from what this
RUN actually judged, never a fixed sentence.
"""
lede = ('This report solves {0}.'.format(_join_and(solved))
if solved else
'Nothing on this deck could be solved -- every model below is '
'NOT JUDGED, with its own reason.')
tail = (' It does not compute {0} -- see “Everything this tool '
'does not compute yet” below.'.format(_join_and(not_solved))
if not_solved else '')
return '<p class="scope-note">{0}{1}</p>'.format(R.esc(lede), R.esc(tail))
def build_summary(handling_results, ip_result, ip_reason):
"""(overall_verdict, html) -- the Summary card: one PASS/WARN/FAIL line
per solved model (item 3 of the results-page redesign), an overall
verdict, and the scope paragraph that replaces the stale banner.
Nothing here computes a ratio -- every util handed to `_stagebox` came
out of `capacity`/`inplace` already; this only picks the governing one
per model and bands it PASS/WARN/FAIL for display (`_verdict`).
"""
boxes = []
verdicts = []
solved, not_solved = [], []
for hr in handling_results:
util = hr['gov'][1] if hr['gov'] is not None else None
boxes.append(_stagebox(hr['title'], hr['verdict'], util,
hr.get('detail') or 'no reason given'))
if hr['verdict'] is not None:
verdicts.append(hr['verdict'])
solved.append(hr['title'].replace('Handling — ', 'Handling '))
else:
not_solved.append(hr['title'])
ip_service = None
if ip_result is None:
boxes.append(_stagebox(
'In-Place Strength', None, None,
ip_reason or 'in-place inputs not supplied'))
not_solved.append('In-Place strength (flexure, axial, shear)')
elif ip_result.refusal:
boxes.append(_stagebox('In-Place Strength', None, None,
ip_result.refusal))
not_solved.append('In-Place strength (flexure, axial, shear)')
else:
v = _verdict(ip_result.gov_util)
if ip_result.gov_util is not None:
detail = '{0} {1} at {2} under {3}'.format(
R.esc(ip_result.gov_check or '?'),
R.n(ip_result.gov_util, 2), R.fmt_ft_in(ip_result.gov_x or 0.0),
R.esc(ip_result.gov_combo or '?'))
else:
detail = 'no station could be judged'
boxes.append(_stagebox('In-Place Strength', v, ip_result.gov_util,
detail))
if v is not None:
verdicts.append(v)
solved.append('in-place strength (flexure, axial, shear)')
else:
not_solved.append('in-place strength (flexure, axial, shear)')
ip_service = getattr(ip_result, 'service', None)
if ip_service is None:
reason = (ip_reason if ip_result is None else
(ip_result.refusal if ip_result.refusal else
'no service run was assembled'))
boxes.append(_stagebox('In-Place Service', None, None,
reason or 'not computed'))
not_solved.append('in-place service (stress, deflection vs limit)')
else:
cands = []
if ip_service.tension_util is not None:
cands.append(('tension', ip_service.tension_util,
ip_service.max_tension_x))
if ip_service.compression_util is not None:
cands.append(('compression', ip_service.compression_util,
ip_service.max_compression_x))
d = ip_service.deflection
if d is not None and d.util is not None:
cands.append(('deflection', d.util, None))
if cands:
name, util, x = max(cands, key=lambda c: c[1])
v = _verdict(util)
loc = ' at {0}'.format(R.fmt_ft_in(x)) if x is not None else \
' (governing span)'
boxes.append(_stagebox('In-Place Service', v, util,
'{0} {1}{2}'.format(name, R.n(util, 2),
loc)))
verdicts.append(v)
solved.append('in-place service (stress, deflection vs limit)')
else:
boxes.append(_stagebox(
'In-Place Service', None, None,
'every station dashed with its reason'))
not_solved.append('in-place service (stress, deflection vs '
'limit)')
overall = _overall_verdict(verdicts)
overall_cls = overall if overall != 'NOT JUDGED' else 'NOTRUN'
verdict_text = {
'PASS': 'All solved checks satisfied',
'WARN': 'Solved, with at least one check close to its limit',
'FAIL': 'One or more solved checks not satisfied',
'NOT JUDGED': 'Nothing on this deck could be judged',
}[overall]
html = (
'<div class="verdict"><b class="{0}">{1}</b><span>{2}</span></div>'
.format(overall_cls, overall, R.esc(verdict_text))
+ '<div class="stageboxes">{0}</div>'.format(''.join(boxes))
+ _scope_statement(solved, not_solved))
return overall, html
def build_analysis_page(deck):
"""The panelanalysis/1 RESULTS page -- read-only, Eriksson-organised.
The input side lives entirely in the 前端 now (`tools/pa_web/static/
index.html`); this page never draws an `<input>`/`<select>` and never
wears a DECK/NOT READ/COMPUTED badge (see `PROV_WORD`'s docstring). Top
of page: title line, then a Summary card (`build_summary`) with one
PASS/WARN/FAIL line per solved model and an accurate scope statement in
place of the old fixed banner. Summary and the governing section stay
open; everything else is collapsed with its own one-line result on the
closed header (`_dsec`'s `tag`).
"""
schema.check_schema(deck)
panel = None
panel_error = None
try:
panel = schema.to_panel(deck)
except schema.DeckError as exc:
panel_error = str(exc)
if panel is None:
# The deck does not describe a panel at all. Say so, loudly, and stop
# -- there is nothing to solve or draw.
body = (
'<h1>Panel analysis</h1>\n'
'<div class="notjudged-box"><b>NOT JUDGED.</b> This deck does not '
'describe a panel that can be analysed: {0}</div>'.format(
R.notate(panel_error or 'unknown error')))
return ('<meta charset="utf-8">\n<title>panel analysis</title>\n'
'<style>{css}</style>\n<main>\n{body}\n</main>\n'.format(
css=_report_css(), body=body))
handling_results = handling_stage_sections(deck, panel)
ip_result, ip_reason = solve_in_place(deck, panel)
pi = deck.get('project_information') or {}
design = pi.get('design_number') or panel.design_number or '?'
ip_solved = ip_result is not None and not ip_result.refusal
wind_html = sec_in_place_wind(deck, panel)
overall, summary_html = build_summary(handling_results, ip_result,
ip_reason)
# Which ONE section opens by default alongside the Summary: the worst
# judged item, i.e. whichever governing ratio the overall verdict above
# actually came from. Nothing opens beyond the Summary on a NOT JUDGED
# deck -- there is no governing ratio to point at.
_RANK = {'FAIL': 3, 'WARN': 2, 'PASS': 1}
gov_key = None # 'handling:<i>' | 'strength' | 'service'
gov_rank = -1
gov_util = -1.0
for i, hr in enumerate(handling_results):
if hr['verdict'] and _RANK[hr['verdict']] >= gov_rank:
util = hr['gov'][1]
if (_RANK[hr['verdict']] > gov_rank or util > gov_util):
gov_key, gov_rank, gov_util = ('handling:{0}'.format(i),
_RANK[hr['verdict']], util)
if ip_result is not None and not ip_result.refusal and \
ip_result.gov_util is not None:
v = _verdict(ip_result.gov_util)
if v and (_RANK[v] > gov_rank or
(_RANK[v] == gov_rank and ip_result.gov_util > gov_util)):
gov_key, gov_rank, gov_util = ('strength', _RANK[v],
ip_result.gov_util)
svc = getattr(ip_result, 'service', None) if ip_result else None
if svc is not None:
svc_cands = [u for u in (svc.tension_util, svc.compression_util,
svc.deflection.util
if svc.deflection else None) if u is not None]
if svc_cands:
svc_util = max(svc_cands)
v = _verdict(svc_util)
if v and (_RANK[v] > gov_rank or
(_RANK[v] == gov_rank and svc_util > gov_util)):
gov_key, gov_rank, gov_util = ('service', _RANK[v], svc_util)
sections = [
('General / Project Information', sec_analysis_general(deck, panel),
'', False),
('Concrete Extents', sec_extents(panel, None, readonly=True), '',
False),
('Materials', sec_materials(panel, None, None, readonly=True), '',
False),
('Prestress / Reinforcement',
sec_prestress(panel, None, None, readonly=True) +
sec_mild_bars(deck, readonly=True), '', False),
('Structural Model (supports, restraints, bearing)',
sec_analysis_structural_model(deck), '', False),
('Settings', sec_analysis_settings(deck), '', False),
('Loading Summary', sec_loading(panel, readonly=True), '', False),
('Section Properties', sec_section_properties(panel), '', False),
]
for i, hr in enumerate(handling_results):
sections.append((hr['title'], hr['body'], hr['tag'],
gov_key == 'handling:{0}'.format(i)))
sections.append(('In-Place Wind -- Zones (as entered)',
sec_wind_zones(deck), '', False))
if wind_html is not None:
sections.append(('In-Place Wind (ASCE C&C, computed)', wind_html,
'', False))
strength_tag = ''
service_tag = ''
if ip_result is not None and not ip_result.refusal:
v = _verdict(ip_result.gov_util)
if v is not None:
strength_tag = _sec_tag(ip_result.gov_check or '?',
ip_result.gov_util,
ip_result.gov_x or 0.0, v)
if svc is not None:
svc_cands = [('tension', svc.tension_util, svc.max_tension_x),
('compression', svc.compression_util,
svc.max_compression_x)]
if svc.deflection is not None and svc.deflection.util is not None:
svc_cands.append(('deflection', svc.deflection.util, None))
svc_cands = [c for c in svc_cands if c[1] is not None]
if svc_cands:
name, util, x = max(svc_cands, key=lambda c: c[1])
v = _verdict(util)
if v is not None:
service_tag = ('<span class="sec-tag {0}">{1} {2}{3} '
'&mdash; {0}</span>'.format(
v, name, R.n(util, 2),
' at {0}'.format(R.fmt_ft_in(x))
if x is not None else ''))
sections += [
('In-Place Load Case Results' if ip_solved else
'In-Place Load Case (not computed)',
sec_in_place_load_cases(ip_result, ip_reason), '', False),
('In-Place Load Combination Results' if ip_solved else
'In-Place Load Combination (not computed)',
sec_in_place_combinations(ip_result, ip_reason), '', False),
('In-Place Strength Summary' if ip_solved else
'In-Place Strength (not computed)',
sec_in_place_strength(ip_result, ip_reason), strength_tag,
gov_key == 'strength'),
('In-Place Service Summary' if ip_solved else
'In-Place Service (not computed)',
sec_in_place_service(ip_result, ip_reason), service_tag,
gov_key == 'service'),
('Everything this tool does not compute yet',
sec_analysis_placeholders(), '', False),
]
# `project_name` / `client_name` / `job_number` reach the deck via
# `panelanalysisform.build_deck` (2026-09 fix, alongside this redesign --
# that dict never carried them through before, even though the form
# collects all three); `location` / `project_location` have no source on
# the form at all today and stay conditional for whenever one exists.
project = pi.get('project_name') or pi.get('project') or None
location = pi.get('location') or pi.get('project_location') or None
client = pi.get('client_name') or None
job_no = pi.get('job_number') or None
sub_bits = ['Design {0}'.format(R.esc(design)),
'generated {0}'.format(
datetime.datetime.now().strftime('%Y-%m-%d %H:%M'))]
proj_bits = [str(b) for b in (project, location) if b]
if proj_bits:
sub_bits.append(R.esc(', '.join(proj_bits)))
if client:
sub_bits.append('client {0}'.format(R.esc(str(client))))
if job_no:
sub_bits.append('job {0}'.format(R.esc(str(job_no))))
body = ('<h1>{mark} &mdash; panel analysis</h1>\n'
'<p class="sub">{sub}</p>\n'.format(
mark=R.esc(panel.mark),
sub=' &middot; '.join(sub_bits))
+ summary_html
+ ''.join(_dsec(title, html, tag, open_)
for title, html, tag, open_ in sections))
return ('<meta charset="utf-8">\n<title>{mark} panel analysis</title>\n'
'<style>{css}</style>\n<main>\n{body}\n</main>\n'
'{hover}'.format(
mark=R.esc(panel.mark), css=_report_css(), body=body,
hover=R.hover_script()))
# --------------------------------------------------------------------- page
def build_page(deck_path, spec_override=None):
with io.open(deck_path, 'r', encoding='utf-8') as fh:
job = json.load(fh)
if (job.get('schema') or '').startswith('panelanalysis/'):
return build_analysis_page(job)
panel = build_panel(job)
job_root = resolve_job_root(deck_path)
spec, spec_path, design, spec_reason = load_spec(job, job_root,
spec_override)
know, layers, know_error = resolve_knowledge(job_root)
sections = [
('General / Project Information',
sec_general(job, panel, spec, spec_path, design, spec_reason,
know)),
('Concrete Extents', sec_extents(panel, spec)),
('Materials', sec_materials(panel, spec, know)),
('Prestress / Reinforcement', sec_prestress(panel, spec, know)),
('Structural Model (supports, bracing, connections)',
sec_structural_model(job, panel)),
('Settings', sec_settings(panel, know)),
('Loading Summary', sec_loading(panel)),
('Section Properties', sec_section_properties(panel)),
('Results -- Flexure & Shear Capacity', sec_capacity(panel)),
('In-Place Strength / Service (not computed)',
'<div class="placeholder-box">Not computed -- needs the load-case '
'engine. See the placeholder list below.</div>'),
('Horizontal Shear Analysis (not computed)',
'<div class="placeholder-box">Not computed -- needs both the '
'load-case engine and a read connector layout '
'(source.connectors, see Structural Model).</div>'),
('Everything this demo does not compute yet', sec_placeholders()),
]
banner = (
'<div class="demo-banner"><b>This page is an input form, not a '
'report.</b> Every field is an <code>&lt;input&gt;</code>, '
'pre-filled where a real source supplied a value and left empty '
'where nothing did -- a chip beside each one names that source. '
'<b>Editing a field recomputes nothing</b> on this page; it only '
'flips the chip to EDITED. That is the demo: the analysis pipeline '
'behind this form is the next phase, not something faked here.'
'</div>')
legend = (
'<p class="legend">' + ' &nbsp; '.join(
'{0} {1}'.format(chip(s), {
'deck': 'the frozen model export',
'spec': 'the design extract (spec YAML)',
'knowledge': 'current merged plant/job knowledge',
'computed': "this tool's own arithmetic",
'absent': 'looked for, not found',
'placeholder': 'not computed -- no engine yet',
'planned': 'named future input, not built',
}[s]) for s in ('deck', 'spec', 'knowledge', 'computed', 'absent',
'placeholder', 'planned')) + '</p>')
if know_error:
legend += ('<p class="fieldnote">Knowledge layers could not be '
'resolved ({0}); every KNOWLEDGE field above fell back '
'to this tool\'s own default.</p>'.format(
R.esc(know_error)))
body = ('<h1>{mark} &mdash; panel analysis (demo)</h1>\n'
'<p class="sub">Design {design} &middot; generated {now}</p>\n'
'{banner}{legend}\n'.format(
mark=R.esc(panel.mark), design=R.esc(design or '?'),
now=datetime.datetime.now().strftime('%Y-%m-%d %H:%M'),
banner=banner, legend=legend)
+ ''.join(R._sec(title, html) for title, html in sections))
return ('<meta charset="utf-8">\n<title>{mark} panel analysis</title>\n'
'<style>{css}{extra}</style>\n<main>\n{body}\n</main>\n'
'{hover}<script>{js}</script>'.format(
mark=R.esc(panel.mark), css=R.CSS, extra=EXTRA_CSS, body=body,
hover=R.hover_script(), js=EDIT_JS))
# ------------------------------------------------------------------- CLI
def main(argv=None):
ap = argparse.ArgumentParser(
description='Render a panel-analysis demo page for one job deck.')
ap.add_argument('deck', help='path to a *.job.json deck')
ap.add_argument('-o', '--out', required=True, help='output .html path')
ap.add_argument('--spec', default=None,
help='design-extract YAML to use instead of the '
'auto-located one')
ap.add_argument('--open', action='store_true',
help='open the rendered page in a browser')
args = ap.parse_args(argv)
html = build_page(args.deck, spec_override=args.spec)
with io.open(args.out, 'w', encoding='utf-8') as fh:
fh.write(html)
print('wrote {0}'.format(args.out))
if args.open:
webbrowser.open('file://' + os.path.abspath(args.out))
return 0
if __name__ == '__main__':
sys.exit(main())