#!/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 , 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 ``, 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/.
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
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 '{2}'.format(
source, title, text, idattr)
def finput(value, source, detail=None, width=10):
"""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.
"""
fid = _next_id()
val = '' if value is None else str(value)
return (
''
'{chip}'.format(
fid=fid, w=width, val=R.esc(val),
chip=chip(source, detail, chip_id=fid + '-chip')))
def fselect(value, options, source, detail=None):
fid = _next_id()
opts = ''.join(
''.format(
R.esc(o), ' selected' if o == value else '')
for o in options)
return (
'{chip}'
.format(fid=fid, opts=opts,
chip=chip(source, detail, chip_id=fid + '-chip')))
def field_row(label, value, source, detail=None, width=12):
return '
'.format(
R.esc(label), fselect(value, options, source, detail))
def note_row(label, text):
"""A row with no field at all -- prose, not a parameter."""
return '
'.format(cls, ''.join(rows_html))
def fmt(value, digits=2):
return '' if value is None else R.n(value, digits)
# ---------------------------------------------------------- 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(''.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(''.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(''.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(''
.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.n(panel.thickness_in, 2).rstrip('0').rstrip('.')))
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 '—'.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 '—'.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)}
"""
EDIT_JS = """
function wtEdited(id){
var chip = document.getElementById(id + '-chip');
if(chip){ chip.textContent = 'EDITED'; chip.className = 'chip chip-edited'; }
}
"""
# ------------------------------------------------------------------ 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):
rows = [
field_row('Length', R.fmt_ft_in(panel.length_ft), 'deck'),
field_row('Width', R.fmt_ft_in(panel.width_ft), 'deck'),
field_row('Thickness (in)', panel.thickness_in, 'deck'),
select_row('Finish', panel.finish, ['smooth', 'exposed'], 'deck'),
select_row('Product type', panel.product_type,
['flat', 'flat_reveal', 'fluted', 'sculptured'], 'deck'),
]
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 fmt(lo), block.get('max_text')
or fmt(hi), 'inside' if in_range else 'OUTSIDE')))
body = ['{0}'.format(panel_plan_svg(panel)),
table(rows)]
if panel.blockouts:
cells = ['
Recesses (blind pockets -- not cut into the '
'section; see model.Recess)
' + table(cells, 'cells'))
return ''.join(body)
def sec_materials(panel, spec, know):
rows = [
field_row("f'c (psi)", panel.fc_psi, 'deck'),
field_row("f'ci (psi)", panel.fci_psi, 'deck'),
field_row('Unit weight (pcf)', panel.unit_weight_pcf, 'deck'),
]
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)))
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):
pre = panel.prestress
if pre is None:
body = ['
This deck carries no prestress block '
'at all. Per model.Prestress\'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.
']
if spec and (spec.get('reinforcing') or {}).get('strand_groups'):
sg = spec['reinforcing']['strand_groups']
body.append('
Worth a second look: '
'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.
'
.format(sum(g.get('qty') or 0 for g in sg)))
body.append(_spec_strand_table(sg))
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')]))
return ''.join(body)
rows = [
field_row('Strand diameter (in)', pre.diameter_in, 'deck'),
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')),
field_row('Transfer length override (in)', 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'),
field_row('Computed transfer length l_t (in)',
round(prestress.transfer_length_in(pre), 2), 'computed',
"ACI 318 f_se*db/3, from this deck's own jacking force"),
]
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 = ['
Mark
y (ft)
z (in)
'
'
Area (in²)
Initial pull (lb)
'
'
f_se (ksi)
']
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(
'
' + table(cells, 'cells'))
if spec:
body.append(_spec_strand_table(
(spec.get('reinforcing') or {}).get('strand_groups') or []))
return ''.join(body)
def _spec_strand_table(groups):
if not groups:
return ''
cells = ['
'.format(
R.esc(label), R.notate(html_value), chip(source))
def sec_structural_model(job, panel):
body = []
stages = job.get('stages') or []
cells = ['
Stage
Span
Rig mode
'
'
PCI case
Picks / supports
']
for st in stages:
if st.get('supports') is not None:
picks = ' '.join(
finput(round(x, 3), 'deck', width=6) for x in st['supports'])
picks += ' (dunnage support x, ft)'
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, by_mark.get(m, {}).get('x'),
by_mark.get(m, {}).get('y'))
for m in marks)
cells.append('
{0}
{1}
{2}
'
'
{3}
{4}
'.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('
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.
')
conn = (job.get('source') or {}).get('connectors') or {}
why = conn.get('why') or ''
ccells = ['
Field
Value
']
for key in ('family', 'count', 'per_row', 'spacing_in',
'capacity_tension_lb', 'capacity_shear_lb',
'stiffness_lb_per_in', 'declared_action'):
ccells.append('
{0}
{1}
'.format(
R.esc(key), finput(conn.get(key), 'absent', width=10)))
body.append(table(ccells, 'cells'))
if why:
body.append('
{0}
'.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):
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'),
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'),
]
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'))
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'))
body = [table(rows)]
body.append('
Wind / lateral inputs (planned)
')
body.append('
Named by the owner as a future input '
'source, not built here: a wallanalysis '
'Project Config module supplying wind speed, Kzt, '
'the ASCE edition and a parameter table he will supply '
'later.
Not computed: 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.
L/10 stations, Eriksson\'s own "Design '
'Parameters" convention (E1-wpb10-eriksson-package.md SS2.6). '
'From panellift.section.NetSection -- weak-axis '
'bending through the thickness, openings removed.
')
return note + '
' + table(rows, 'stations') + \
'
'
def sec_capacity(panel):
if panel.prestress is None or not panel.prestress.strands:
return ('
This piece carries no '
'prestress in the deck, and panellift.capacity '
'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*Vn (concrete alone) does not need '
'prestress and is shown below.
' +
sec_shear_only(panel))
groups = []
if panel.fci_psi:
groups.append(('At release, f′ci = {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′c = {0} psi '
'-- governs transportation / erection'.format(
R.n(panel.fc_psi)), panel.fc_psi))
body = ['
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.
']
xs = key_stations(panel)
for title, fc in groups:
rows = ['
Loc (ft)
'
'
Sagging (θ=0°)
'
'
Hogging (θ=180°)
'
'
fse (ksi)
'
'
φVn (k)
'
'
φ
φMn (k-ft)
'
'
Mcr (k-ft)
'
'
φ
φMn (k-ft)
'
'
Mcr (k-ft)
']
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(
'
{0}
'
'
{1}
{2}
{3}
'
'
{4}
{5}
{6}
'
'
{7}
{8}
'.format(
R.n(x, 2),
_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('
']
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('
'
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 '
{0}
'.format(
''.join('
{0}
'.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 '—'.format(title)
if value > fail_above:
return '{0} FAIL'.format(R.n(value, 2))
return '{0}'.format(R.n(value, 2))
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 = ['
Support
x (ft)
Type
'
'
Reaction (lb)
']
total_r = 0.0
for i, line in enumerate(analysis.rig.lines, 1):
t = types.get(round(line.x, 6), 'support')
rows.append('
Sum of reactions '
'{0} lb against total factored self weight '
'{1} lb — {3}. '
'Reactions must sum to the load; this is the closure that says the '
'solve is real, not that a stress passed.
'.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(
'
Load rescaled to the deck\'s stated weight; '
'the geometry came out {0:+.1%} against it '
'(section.weight_reconciliation).
'.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/φMn', 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/φVn ({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(
'
Governing check (this stage): '
'{0} at {1:.2f} ft, {2}.
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)
')
out.append('
The governing 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).
')
return ''.join(out), gov
def sec_handling(analysis, refusal, panel, deck):
"""The self-weight Handling model: reactions, diagrams, capacity + stress.
Or, where the engine refused, a single NOT-JUDGED box carrying the engine's
own words -- an unsupported beam must never render as fine.
Runs the handling STAGES the deck asks for (default: Stripping x1.4 at
f'ci, Transportation x1.5 at f'c, Erection x1.2 at f'c -- FIX 2), each
solved on its own multiplier and strength, each getting the full flexure /
shear / stress check. The first stage drives the reactions and diagrams
shown at the top; every stage gets its own demand-vs-capacity and stress
tables.
"""
if analysis is None:
return (
'
NOT JUDGED. 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.
'.format(
R.notate(refusal or 'no reason given')))
stages = schema.handling_stages(deck, panel)
if not stages:
stages = [schema.handling_stage(deck, panel)]
body = ['
The panel as a beam spanning its '
'structural_model.supports, carrying its own factored self weight. '
'Solved by panellift.statics (continuous beam on '
'rigid supports); the capacity columns are '
'panellift.capacity, strand only.
')
body.append(handling_reactions_table(analysis, deck))
shear = [v / R.LB_PER_KIP for v in analysis.diagram.shear_lb]
moment = [m / R.LB_PER_KIP for m in analysis.diagram.moment_lbft]
body.append('
L/20 stations plus every opening-edge '
'discontinuity. M and V are the solved self-weight demand; '
'φMn, Mcr, φVn and '
'fse 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. The governing check is the '
'highest utilisation across flexure, shear and stress — '
'often the compression stress, not flexure.
')
xs = key_stations(panel)
xmax = panel.length_ft
overall = None # (stage_name, check_name, util, x)
for stage in stages:
an, ref = solve_stage(panel, stage)
label = "{0} — ×{1} at f′{2} = {3} psi".format(
R.esc(stage.name), R.n(stage.multiplier, 2),
'ci' if (panel.fci_psi and stage.fc_psi == panel.fci_psi) else 'c',
R.n(stage.fc_psi))
if an is None:
body.append('
{1}'.format(label, html))
if gov is not None and (overall is None or gov[1] > overall[2]):
overall = (stage.name, gov[0], gov[1], gov[3] if len(gov) > 3
else gov[2])
if overall is not None:
body.insert(1, '
'
.format(R.esc(overall[0]), overall[1], overall[3],
_ratio_cell(overall[2], None)))
return ''.join(body)
def sec_analysis_general(deck, panel):
pi = deck.get('project_information') or {}
rows = [
field_row('Mark', panel.mark, 'deck'),
field_row('Design number', pi.get('design_number')
or panel.design_number or '', 'deck'),
field_row("f'c (psi)", panel.fc_psi, 'deck'),
field_row("f'ci (psi)", panel.fci_psi, 'deck'),
field_row('Prestress loss (%)', pi.get('prestress_loss_pct'), 'deck'),
]
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):
body = []
sm = deck.get('structural_model') or {}
body.append('
Supports (the Handling beam spans these)
')
scells = ['
#
x (ft)
Type
']
for i, s in enumerate(sm.get('supports') or [], 1):
scells.append('
{0}
{1}
{2}
'.format(
i, finput(s.get('x_ft'), 'deck', width=6),
finput(s.get('type') or 'support', 'deck', width=10)))
body.append(table(scells, 'cells'))
for key, label in (('restraints', 'Restraints'), ('bearing', 'Bearing')):
blk = sm.get(key)
if isinstance(blk, dict) and 'reason' in blk and blk.get('value') is None:
body.append('
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.
')
def _in_place_unwired(reason):
return ('
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.
'.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 ('
No in-place strength check could be '
'judged — every station dashed with its reason.
'.format(
R.esc(result.gov_combo), R.esc(result.gov_check),
result.gov_x or 0.0, _ratio_cell(result.gov_util, None)))
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 unwired
# placeholder both 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):
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'),
field_row('Risk Category', a.get('risk_category'), 'deck'),
field_row('Exposure Category', a.get('exposure'), 'deck'),
field_row('Enclosure Classification', a.get('enclosure'), 'deck'),
field_row('ASCE code version', a.get('code_version'), 'deck'),
field_row('C&C chapter', a.get('cc_chapter'), 'deck'),
field_row('Mean roof height h (ft)', a.get('mean_roof_height_ft'),
'deck'),
field_row('Effective wind area (ft^2)',
a.get('effective_wind_area_ft2'), 'deck'),
]
# 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')))
body.append('
')
elif res is not None and res.not_judged:
body.append('
NOT JUDGED. {0}
'
.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)),
'
Governing out-of-plane pressure (psf)
{0}
'
.format(finput(None if pressure is None else R.n(pressure, 1),
prow_source, detail)),
]
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('
Precedence: direct > computed > blank
')
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 ('
NOT JUDGED. {0}
'
.format(R.notate(result.refusal)))
body = ['
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:
']
rows = ['
Case
Symbol
Built?
'
'
Peak axial (k)
Peak |M| (k-ft)
'
'
Peak |V| (k)
']
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('
{0}
{1}
{2}
'
'
{3}
{4}
{5}
'.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('
{0}
'.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 ('
NOT JUDGED. {0}
'
.format(R.notate(result.refusal)))
body = [_inplace_gov_banner(result)]
body.append('
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.
')
rows = ['
LC
Combination
Peak Pu (k)
'
'
Peak |Mu| (k-ft)
Peak |Vu| (k)'
'
Governing
Util
']
for cr in result.combos:
gov = ('{0} @ {1:.1f} ft'.format(cr.gov_check, cr.gov_x)
if cr.gov_check else '—')
flag = ' *' if cr.combo.has_unbuilt else ''
rows.append('
* combination carries a 1.0T / 1.6H or '
'seismic-E term this slice does not model.
')
return ''.join(body)
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 ('
NOT JUDGED. {0}
'
.format(R.notate(result.refusal)))
if not result.combos:
return ('
NOT JUDGED. No strength '
'combinations were assembled.
')
# 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('
Per-station strength under the governing '
'combination {0} ({1}). Flexure φMn and '
'shear φVn REUSE panellift.capacity '
'(strand Whitney block; detailed Vci/Vcw); axial ΦPn'
'(C) and ΦPcrit 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 φMn '
'(capacity is strand-only, the concurrent capacity layer\'s '
'gap), never a fabricated pass.
Stub, this slice: 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.
')
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-Δ (2nd-order, docs sec.5.3):'
' {0}
'.format(R.notate(pd.why or 'not computed')))
return ('
P-Δ (2nd-order, docs sec.5.3): '
'moment magnifier δns = {0} '
'(γ = PuL²/8EIeff = {1}, '
'EIeff = 0.875·EcIg, '
'βd=0 short-term wind) over the {2} ft 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.
'.format(
R.n(pd.magnifier, 3), R.n(pd.gamma or 0.0, 4),
R.n(pd.span_ft, 1),
'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 = ('
Loc (ft)
Tension (psi)
'
'
Cracking (psi)
Util
M (k-ft)
'
'
P (k)
Combo
')
else:
hdr = ('
Loc (ft)
Comp. (psi)
Limit (psi)
'
'
Util
M (k-ft)
P (k)
Combo
')
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('
'.format(
label, (R.n(val, digits) + unit) if val is not None else
'—'))
r('Δwind (elastic, service wind)', d.delta_wind_in)
r('Δbow (initial bow)', d.delta_bow_in)
r('ΔPu (eccentric axial)', d.delta_pu_in)
r('Δi (first-order sum)', d.delta_i_in)
r('Δe (P-Δ increment)', d.delta_e_in)
r('Δtotal = Δi/(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 = ['
Deflection is elastic (double integration of '
'the governing service-wind moment field over the {0} ft span, '
'gross EIeff) plus the PCI 5.10.3.1 P-Δ iteration '
'(docs sec.6.3), against the per-job L/n limit.
'.format(
R.n(d.span_ft, 1))]
body.append(table(rows, 'kv'))
if d.util is not None:
body.append('
Deflection util: {0}
'
.format(_ratio_cell(d.util, None)))
if d.why:
body.append('
Deflection limit NOT '
'JUDGED. {0}
'.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 ('
NOT JUDGED. {0}
'
.format(R.notate(result.refusal)))
svc = getattr(result, 'service', None)
if svc is None:
return ('
In-Place service (ASCE 7-16 sec.2.4 / '
'docs sec.6). Extreme-fibre stress REUSES '
'capacity.extreme_fibre_stresses (Pi/A '
'−/+ (M+Pie)c/I) with the service axial layered on. '
'Worst tension {0} psi @ {1} ft (vs {2} psi cracking); worst '
'compression {3} psi @ {4} ft (vs 0.6 f'c). '
'Tension util {5}; compression util {6}.
'.format(
R.n(svc.max_tension_psi, 1),
R.n(svc.max_tension_x or 0.0, 1), R.n(tension_lim, 1),
R.n(svc.max_compression_psi, 1),
R.n(svc.max_compression_x or 0.0, 1),
_ratio_cell(svc.tension_util, None),
_ratio_cell(svc.compression_util, None)))
body = [banner]
body.append('
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.
')
body.append(_service_deflection_block(svc.deflection))
for n in svc.notes:
body.append('
{0}
'.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 '
{0}
'.format(
''.join('
{0}
'.format(R.notate(i)) for i in items))
def build_analysis_page(deck):
"""The panelanalysis/1 page: Eriksson sections, Handling actually solved."""
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 = (
'
Panel analysis
\n'
'
NOT JUDGED. This deck does not '
'describe a panel that can be analysed: {0}
'.format(
R.notate(panel_error or 'unknown error')))
return ('\npanel analysis\n'
'\n\n{body}\n\n'.format(
css=R.CSS, extra=EXTRA_CSS, body=body))
analysis, refusal = solve_handling(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)
sections = [
('General / Project Information', sec_analysis_general(deck, panel)),
('Concrete Extents', sec_extents(panel, None)),
('Materials', sec_materials(panel, None, None)),
('Prestress / Reinforcement', sec_prestress(panel, None, None)),
('Structural Model (supports, restraints, bearing)',
sec_analysis_structural_model(deck)),
('Settings', sec_analysis_settings(deck)),
('Loading Summary', sec_loading(panel)),
('Section Properties', sec_section_properties(panel)),
('Handling -- Self-Weight Model (solved)',
sec_handling(analysis, refusal, panel, deck)),
]
if wind_html is not None:
sections.append(('In-Place Wind (ASCE C&C, computed)', wind_html))
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)),
('In-Place Load Combination Results' if ip_solved else
'In-Place Load Combination (not computed)',
sec_in_place_combinations(ip_result, ip_reason)),
('In-Place Strength Summary' if ip_solved else
'In-Place Strength (not computed)',
sec_in_place_strength(ip_result, ip_reason)),
('In-Place Service Summary' if ip_solved else
'In-Place Service (not computed)',
sec_in_place_service(ip_result, ip_reason)),
('Everything this tool does not compute yet',
sec_analysis_placeholders()),
]
solved = analysis is not None
banner = (
'
This page solves the self-weight '
'Handling model. The panel is analysed as a beam on its '
'structural_model.supports, carrying its own weight, reusing '
'panellift\'s engine — reactions, shear/moment '
'diagrams and a per-station demand-vs-capacity table below. The '
'In-Place model (panel on pins under lateral + gravity) is a '
'later stage and every In-Place section says so rather than showing a '
'zero. {0}
'.format(
'The Handling solve ran.' if solved else
'The Handling solve was NOT JUDGED '
'— see the Handling section.'))
body = ('
Not computed -- needs both the '
'load-case engine and a read connector layout '
'(source.connectors, see Structural Model).
'),
('Everything this demo does not compute yet', sec_placeholders()),
]
banner = (
'
This page is an input form, not a '
'report. Every field is an <input>, '
'pre-filled where a real source supplied a value and left empty '
'where nothing did -- a chip beside each one names that source. '
'Editing a field recomputes nothing 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.'
'
')
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')) + '
')
if know_error:
legend += ('
Knowledge layers could not be '
'resolved ({0}); every KNOWLEDGE field above fell back '
'to this tool\'s own default.
'.format(
R.esc(know_error)))
body = ('
{mark} — panel analysis (demo)
\n'
'
Design {design} · generated {now}
\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 ('\n{mark} panel analysis\n'
'\n\n{body}\n\n'
'{hover}'.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())