25592fa6c2
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
2863 lines
136 KiB
Python
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}>—</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}>—</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}>—</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²)</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²)'
|
|
'</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 — 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 — 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 += ' (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&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²)'
|
|
'</th><th>CGy (in)</th><th>I<sub>z</sub> (in⁴)</th>'
|
|
'<th>S<sub>z</sub> (in³)</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′<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′<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 (θ=0°)</th>'
|
|
'<th colspan="3">Hogging (θ=180°)</th>'
|
|
'<th rowspan="2">f<sub>se</sub> (ksi)</th>'
|
|
'<th rowspan="2">φV<sub>n</sub> (k)</th></tr>'
|
|
'<tr><th>φ</th><th>φM<sub>n</sub> (k-ft)</th>'
|
|
'<th>M<sub>cr</sub> (k-ft)</th>'
|
|
'<th>φ</th><th>φ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>φ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}>—</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} — {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> — <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/φ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/φ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>φ</th><th>φM<sub>n</sub> (k-ft)</th>'
|
|
'<th>M<sub>cr</sub> (k-ft)</th>'
|
|
'<th>φV<sub>n</sub> (k)</th>'
|
|
'<th>f<sub>se</sub> (ksi)</th>'
|
|
'<th>M/φM<sub>n</sub></th><th>V/φ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 & shear (φ from the net tensile strain, '
|
|
'0.65–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 — 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">×{0} at f′<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; φM<sub>n</sub>, M<sub>cr</sub>, '
|
|
'φ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 — 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 — 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 — {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 — every station dashed with its reason.</p>')
|
|
return ('<div class="demo-banner"><b>Governing in-place strength check:</b> '
|
|
'{0} — {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 — '
|
|
'{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 '
|
|
'— 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> '
|
|
'— 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 — 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&C wall pressures</h3>')
|
|
body.append('<p class="fieldnote">From <code>panellift.windload</code> '
|
|
'(ASCE 7 Ch.30 Part 1, h≤60 ft). The wall (GCp) are '
|
|
'figure-read, so every pressure carries ±{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>± (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 > computed > 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 '—')
|
|
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 φM<sub>n</sub> and '
|
|
'shear φV<sub>n</sub> REUSE <code>panellift.capacity</code> '
|
|
'(strand Whitney block; detailed Vci/Vcw); axial ΦP<sub>n'
|
|
'</sub>(C) and Φ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 — a mild-only wall dashes φ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>φM<sub>n</sub> (k-ft)</th><th>M<sub>cr</sub> (k-ft)</th>'
|
|
'<th>M/φM<sub>n</sub></th>'
|
|
'<th>V<sub>u</sub> (k)</th><th>φV<sub>n</sub> (k)</th>'
|
|
'<th>V/φV<sub>n</sub></th>'
|
|
'<th>P<sub>u</sub> (k)</th><th>ΦP<sub>n</sub>(C) (k)</th>'
|
|
'<th>ΦP<sub>crit</sub> (k)</th><th>P/Φ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 → 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-Δ (2nd-order, docs sec.5.3):'
|
|
'</b> {0}</p>'.format(R.notate(pd.why or 'not computed')))
|
|
return ('<p class="fieldnote"><b>P-Δ (2nd-order, docs sec.5.3):</b> '
|
|
'moment magnifier δ<sub>ns</sub> = {0} '
|
|
'(γ = P<sub>u</sub>L²/8EI<sub>eff</sub> = {1}, '
|
|
'EI<sub>eff</sub> = 0.875·E<sub>c</sub>I<sub>g</sub>, '
|
|
'β<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">—</span>'))
|
|
r('Δ<sub>wind</sub> (elastic, service wind)', d.delta_wind_in)
|
|
r('Δ<sub>bow</sub> (initial bow)', d.delta_bow_in)
|
|
r('Δ<sub>Pu</sub> (eccentric axial)', d.delta_pu_in)
|
|
r('Δ<sub>i</sub> (first-order sum)', d.delta_i_in)
|
|
r('Δ<sub>e</sub> (P-Δ increment)', d.delta_e_in)
|
|
r('Δ<sub>total</sub> = Δ<sub>i</sub>/(1−γ)',
|
|
d.delta_total_in)
|
|
r('P-Δ magnifier 1/(1−γ)', 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-Δ 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 '
|
|
'−/+ (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'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'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} '
|
|
'— {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} — panel analysis</h1>\n'
|
|
'<p class="sub">{sub}</p>\n'.format(
|
|
mark=R.esc(panel.mark),
|
|
sub=' · '.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><input></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">' + ' '.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} — panel analysis (demo)</h1>\n'
|
|
'<p class="sub">Design {design} · 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())
|