#!/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 # 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 # ``/`' '{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, readonly=False, word=True): if readonly: return rval(value, source, detail, word=word) 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, readonly=False): return '{0}{1}'.format( R.esc(label), finput(value, source, detail, width, readonly=readonly)) def select_row(label, value, options, source, detail=None, readonly=False): return '{0}{1}'.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 '{0}{1}'.format( R.esc(label), R.notate(text)) def table(rows_html, cls='kv'): return '{1}
'.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(''.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.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 '—'.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)} /* ------------------------------------------- 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/.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/`
`/ 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 = ['
{0}
'.format(panel_plan_svg(panel)), table(rows)] if panel.blockouts: cells = ['Namex0 (ft)y0 (ft)' 'Length (ft)Width (ft)'] for b in panel.blockouts: cells.append('{0}{1}{2}' '{3}{4}'.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('

Blockouts{0}

'.format( ' from model' if readonly else '') + table(cells, 'cells')) if panel.recesses: cells = ['Namex0 (ft)y0 (ft)' 'Length (ft)Width (ft)' 'Concrete behind (in)'] for rc in panel.recesses: cells.append('{0}{1}{2}' '{3}{4}{5}'.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('

Recesses (blind pockets -- not cut into the ' 'section; see model.Recess){0}

'.format( ' from model' 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 = ['

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, 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 = ['Marky (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( '{0}{1}{2}{3}' '{4}{5}'.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('

Strand layout{0}

'.format( ' from model' 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 = ['QtyDiameter (in)Grade (ksi)' 'Initial tension (lb)Role'] for g in groups: cells.append( '{0}{1}{2}{3}' '{4}'.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 '

Strand groups (design extract)

' + 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 = ['#SizeCountArea (in²)' 'Depth (in)DirectionFrom (ft)' 'To (ft)In capacity?'] for i, b in enumerate(bars, 1): longitudinal = b['direction'] == 'Longitudinal' if longitudinal and b['depth_in'] is not None: status = 'yes' elif not longitudinal: status = ('transverse — listed, ' 'not used') else: status = ('not in the capacity (no ' 'depth given)') rows.append( '{0}{1}{2}{3}' '{4}{5}{6}{7}' '{8}'.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 = ['

Mild bars (as entered){0}

'.format( ' from model' if readonly else ''), table(rows, 'cells')] fy = schema.mild_bar_grade_ksi(deck) body.append( '

Longitudinal bars with a depth enter the ' 'flexural capacity (capacity.mild_flexure_at, combined ' 'with any strand by strain compatibility) at fy = {0} ksi ' '(settings.rebar_fy_ksi, 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.

'.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 = '{0}'.format(R.esc(word)) if word \ else '' else: tail = chip(source) return '{0}{1} {2}'.format( R.esc(label), R.notate(html_value), tail) def sec_structural_model(job, panel): body = [] stages = job.get('stages') or [] cells = ['StageSpanRig mode' 'PCI casePicks / supports'] 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('{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('

Rigging, per stage (deck)

' + table(cells, 'cells')) body.append('

Lifting inserts

') lcells = ['MarkFamilyx (ft)' 'y (ft)Capacity (lb)Edge?'] for l in panel.lifters: lcells.append( '{0}{1}{2}{3}' '{4}{5}'.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('

In-place bearing (not built into any stage yet)

') 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.

') bcells = ['Bearingx (ft)Width (in)' 'Condition'] for i in (1, 2): bcells.append( '{0}{1}{2}{3}' .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('

Connections (unread)

') conn = (job.get('source') or {}).get('connectors') or {} why = conn.get('why') or '' ccells = ['FieldValue'] 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, 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('

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.

') 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( '
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.
') return ''.join(body) def sec_section_properties(panel): rows = ['Loc (ft)Width (in)Area (in²)' 'CGy (in)Iz (in⁴)' 'Sz (in³)'] for x, net in section_properties_rows(panel): rows.append( '{0}{1}{2}{3}' '{4}{5}'.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 = ('

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.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('

{0}

{1}
'.format( title, table(rows, 'stations'))) return ''.join(body) def sec_shear_only(panel): xs = key_stations(panel) rows = ['Loc (ft)φVn (k)'] 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('{0}{1}'.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 '
' + table(rows, 'stations') + '
' 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)) # --------------------------------------------------------- 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 'not judged' return ('{1} {2} at {3} — {0}' .format(verdict, check_html, R.n(util, 2), R.fmt_ft_in(x))) def _notjudged_tag(): return 'not judged' def _dsec(title, body_html, tag='', open_=False, cls='section'): """`panellift.report._sec`'s own `
    ` 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 = '{0}{1}'.format(head, tag) return ('
    {2}' '
    {3}
    ' .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 ('
    ' '
    {0}' 'not judged
    ' '

    {1}

    ' .format(R.esc(name), R.notate(detail_html))) pct = '{0:.0%}'.format(util) if util is not None else '-' return ('
    ' '
    {1}' '{0}
    ' '
    {2}' ' utilised
    ' '
    {3}
    ' .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 = ['Supportx (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('{0}{1}{2}' '{3}'.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( '

    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}, {2}.

    '.format( gov[0], R.fmt_ft_in(gov[2]), _ratio_cell(gov[1], None))) hdr = ('Loc (ft)M (k-ft)V (k)' 'φφMn (k-ft)' 'Mcr (k-ft)' 'φVn (k)' 'fse (ksi)' 'M/φMnV/φVn' 'Shear governs') crows = [hdr] for r in rows: cap = r['cap'] shear = r['shear'] crows.append( '{0}{1}{2}' '{3}{4}{5}{6}{7}' '{8}{9}{10}'.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('
    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).

    ') out.append('
    ' + table(crows, 'stations') + '
    ') allow_t = stage.allowable_tension_psi allow_c = stage.allowable_compression_psi out.append('
    Extreme-fibre stress (service): ' 'ft vs {0} psi ({1}), ' 'fc vs {2} psi ({3})
    '.format( R.n(allow_t, 0), R.esc(stage.tension_basis), R.n(allow_c, 0), R.esc(stage.compression_basis))) srows = ['Loc (ft)' 'fform (psi)ffar (psi)' 'ft (psi)ft/allow' 'fc (psi)fc/allow'] for r in rows: sp = r['stress'] srows.append( '{0}{1}{2}' '{3}{4}{5}{6}'.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('
    ' + table(srows, 'stations') + '
    ') 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': ('
    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(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 = ['

    ×{0} at f′{1} ' '= {2} psi. 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.

    ' .format(R.n(stage.multiplier, 2), strength_label, R.n(stage.fc_psi))] body.append('
    {0}
    '.format(R.free_body(an))) body.append('

    Support reactions

    ') 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('

    Shear and moment

    ') body.append('
    {0}
    '.format( R.diagram_plot(an, shear, 'Shear V(x)', 'kip', digits=2, symbol='V'))) body.append('
    {0}
    '.format( R.diagram_plot(an, moment, 'Moment M(x)', 'kip-ft', digits=1, symbol='M'))) body.append('

    Demand vs capacity, per station

    ') 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.

    ') 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('

    Supports (the Handling beam spans these) ' 'from model

    ') scells = ['#x (ft)Type'] for i, s in enumerate(sm.get('supports') or [], 1): scells.append('{0}{1}{2}'.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('

    Restraints (the In-Place panel spans these) ' 'from model

    ') rcells = ['#x (ft)Type' 'GravityThermalLateral?'] any_fixed = False for i, r in enumerate(rows, 1): if str(r.get('type') or '').strip().lower() == 'fixed': any_fixed = True rcells.append( '{0}{1}{2}{3}' '{4}{5}'.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 'no -- Free')) body.append(table(rcells, 'cells')) if any_fixed: body.append('

    A \'Fixed\' restraint is ' 'modelled as a PIN for now — the in-place ' 'solve does not yet carry true rotational fixity.' '

    ') else: blk = sm.get('restraints') if isinstance(blk, dict) and 'reason' in blk and blk.get('value') is None: body.append('

    Restraints: {0}

    ' .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('

    Bearing: {0}

    '.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 = ( '
    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.

    ') return ('
    Governing in-place strength check: ' '{0} — {1} at {2}, {3}.
    '.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 '

    No loading.wind block on '\ 'this deck.

    ' if isinstance(blk, dict) and 'reason' in blk and blk.get('value') is None: return '

    {0}

    '.format(R.notate(blk['reason'])) if not isinstance(blk, dict): return '

    Malformed loading.wind '\ 'block: {0!r}

    '.format(blk) mode = blk.get('mode') body = ['

    Mode: {0}

    '.format( R.esc(mode or 'legacy (given zones, or ASCE / direct-entry -- see ' 'below)'))] if mode == 'project_config': body.append('
    Not computed — ' '{0}.
    '.format(R.esc(schema.WIND_PROJECT_CONFIG_NOT_BUILT))) return ''.join(body) zdefs = blk.get('zones') if not isinstance(zdefs, list): body.append('

    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).

    ') return ''.join(body) rows = ['LabelFrom (ft)To (ft)' 'Width (ft)Pressure (psf)' 'Suction (psf)'] for i, z in enumerate(zdefs): if not isinstance(z, dict): continue rows.append( '{0}{1}{2}{3}' '{4}{5}'.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('

    from model ' '— every column above.

    ') body.append(table(rows, 'cells')) if mode == 'manual': body.append('

    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.

    ') 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('

    ASCE inputs

    ') body.append(table(rows)) elif blk.get('asce_incomplete'): body.append('

    {0}

    '.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('

    Computed C&C wall pressures

    ') body.append('

    From panellift.windload ' '(ASCE 7 Ch.30 Part 1, h≤60 ft). The wall (GCp) are ' 'figure-read, so every pressure carries ±{0} psf. ' '{1}

    '.format( R.n(info.get('uncertainty_psf') or 0.0, 2), R.esc(info.get('cite') or ''))) wrows = ['ZonePositive p (psf)' 'Negative p (psf)± (psf)'] for z in res.zones: wrows.append('{0}{1}{2}' '{3}'.format( z.zone, R.n(z.positive_psf, 1), R.n(z.negative_psf, 1), R.n(z.uncertainty_psf, 2))) body.append('
    ' + table(wrows, 'cells') + '
    ') 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, 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('

    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 = ['CaseSymbolBuilt?' '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 = ['LCCombinationPeak Pu (k)' 'Peak |Mu| (k-ft)Peak |Vu| (k)' 'GoverningUtil'] 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('{0}{1}{2}{3}{4}' '{5}{6}{7}'.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('
    ' + table(rows, 'stations') + '
    ') body.append('

    * combination carries a 1.0T / 1.6H or ' 'seismic-E term this slice does not model.

    ') 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('' .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 ('
    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.

    '.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('

    Shear and moment (governing combination, ' 'envelope)

    ') body.append('

    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.

    ') body.append('
    {0}
    '.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('
    {0}
    '.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 = ('Loc (ft)Mu (k-ft)' 'φMn (k-ft)Mcr (k-ft)' 'M/φMn' 'Vu (k)φVn (k)' 'V/φVn' 'Pu (k)ΦPn(C) (k)' 'ΦPcrit (k)P/ΦPn') rows = [hdr] for sd in cr.stations: cap = sd.cap sh = sd.shear ax = sd.axial rows.append( '{0}{1}{2}{3}{4}' '{5}{6}{7}' '{8}{9}{10}{11}'.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('
    ' + table(rows, 'stations') + '
    ') body.append(_pdelta_note(cr)) body.append('

    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} 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.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 = ('Loc (ft)Tension (psi)' 'Cracking (psi)UtilM (k-ft)' 'P (k)Combo') else: hdr = ('Loc (ft)Comp. (psi)Limit (psi)' 'UtilM (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('{0}{1}{2}{3}' '{4}{5}{6}'.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 '
    ' + table(rows, 'stations') + '
    ' def _service_deflection_block(d): """The deflection breakdown (elastic + P-Delta) vs L/n, docs sec.6.3.""" if d is None: return '' rows = ['TermValue'] # 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('{0}{1}'.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} 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.fmt_ft_in(d.span_ft))] 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 ('
    NOT JUDGED. No service run ' 'was assembled.
    ') tension_lim = svc.stations[0].cracking_limit_psi if svc.stations else 0.0 banner = ( '
    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} (vs {2} psi cracking); worst ' 'compression {3} psi @ {4} (vs 0.6 f'c). ' 'Tension util {5}; compression util {6}.
    '.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('

    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.

    ') combo_rows = ['SCCombinationCite'] for c in svc.combos: combo_rows.append('{0}{1}{2}'.format( R.esc(c.name), R.esc(c.label), R.esc(c.cite))) body.append(table(combo_rows, 'cells')) body.append('

    Tension vs cracking (docs sec.6.1)

    ') body.append(_service_stress_table(svc.stations, 'tension')) body.append('

    Total compression vs 0.6 f'c (docs sec.6.2)

    ') body.append(_service_stress_table(svc.stations, 'compression')) body.append('

    Deflection (docs sec.6.3)

    ') 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 _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 '

    {0}{1}

    '.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 = ( '
    {1}{2}
    ' .format(overall_cls, overall, R.esc(verdict_text)) + '
    {0}
    '.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 ``/`