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('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 = ['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(
'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 + '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 = ['` 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 = ['Support x (ft) Type '
'Reaction (lb) ']
total_r = 0.0
for i, line in enumerate(analysis.rig.lines, 1):
t = types.get(round(line.x, 6), 'support')
rows.append('{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/φMn V/φ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 '
'Gravity Thermal Lateral? ']
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 = ['Label From (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 = ['Zone Positive 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 = ['Case Symbol Built? '
'Peak axial (k) Peak |M| (k-ft) '
'Peak |V| (k) ']
for sym in ('D', 'L', 'W', 'R', 'E', 'T', 'H'):
c = result.cases.get(sym)
if c is None:
continue
pk_p = max((abs(v) for v in c.axial_lb), default=0.0) / 1000.0
pk_m = max((abs(v) for v in c.moment_lbft), default=0.0) / 1000.0
pk_v = max((abs(v) for v in c.shear_lb), default=0.0) / 1000.0
rows.append('{0} {1} {2} '
'{3} {4} {5} '.format(
R.esc(c.name), sym,
'yes' if c.built else R.esc('no -- ' + (c.note or '')),
R.n(pk_p, 1), R.n(pk_m, 1), R.n(pk_v, 1)))
body.append(table(rows, 'cells'))
if result.notes:
for n in result.notes:
body.append('{0}
'.format(R.notate(n)))
return ''.join(body)
def sec_in_place_combinations(result, reason):
"""Load Combination Results: factored demand + governing util per combo."""
if result is None:
return _in_place_unwired(reason)
if result.refusal:
return ('NOT JUDGED. {0}
'
.format(R.notate(result.refusal)))
body = [_inplace_gov_banner(result)]
body.append('The ACI 318-14 / ASCE 7-16 sec.2.3 '
'STRENGTH combinations (docs sec.7), each assembled by '
'superposing the load-case fields. Service combinations are a '
'later slice. A combo carrying 1.0T / 1.6H or seismic E that '
'this slice does not model is flagged; where the job has no '
'such load the term is 0.
')
rows = ['LC Combination Peak Pu (k) '
'Peak |Mu | (k-ft) Peak |Vu | (k)'
' Governing Util ']
for cr in result.combos:
gov = ('{0} @ {1}'.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} '
'(γ = Pu L²/8EIeff = {1}, '
'EIeff = 0.875·Ec Ig , '
'β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) Util M (k-ft) '
'P (k) Combo ')
else:
hdr = ('Loc (ft) Comp. (psi) Limit (psi) '
'Util M (k-ft) P (k) Combo ')
rows = [hdr]
for st in stations:
if kind == 'tension':
val, lim, util = (st.tension_psi, st.cracking_limit_psi,
st.tension_util)
m, p, combo = st.m_tension_lbft, st.p_tension_lb, st.gov_tension_combo
else:
val, lim, util = (st.compression_psi, st.compression_limit_psi,
st.compression_util)
m, p, combo = st.m_comp_lbft, st.p_comp_lb, st.gov_comp_combo
rows.append('{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 = ['Term Value ']
# 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+Pi e)c/I) with the service axial layered on. '
'Worst tension {0} psi @ {1} (vs {2} psi cracking); worst '
'compression {3} psi @ {4} (vs 0.6 f'c). '
'Tension util {5}; compression util {6}.
'.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 = ['SC Combination Cite ']
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 ''.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 ` `/`` and never
wears a DECK/NOT READ/COMPUTED badge (see `PROV_WORD`'s docstring). Top
of page: title line, then a Summary card (`build_summary`) with one
PASS/WARN/FAIL line per solved model and an accurate scope statement in
place of the old fixed banner. Summary and the governing section stay
open; everything else is collapsed with its own one-line result on the
closed header (`_dsec`'s `tag`).
"""
schema.check_schema(deck)
panel = None
panel_error = None
try:
panel = schema.to_panel(deck)
except schema.DeckError as exc:
panel_error = str(exc)
if panel is None:
# The deck does not describe a panel at all. Say so, loudly, and stop
# -- there is nothing to solve or draw.
body = (
'Panel analysis \n'
'NOT JUDGED. This deck does not '
'describe a panel that can be analysed: {0}
'.format(
R.notate(panel_error or 'unknown error')))
return (' \npanel analysis \n'
'\n\n{body}\n \n'.format(
css=_report_css(), body=body))
handling_results = handling_stage_sections(deck, panel)
ip_result, ip_reason = solve_in_place(deck, panel)
pi = deck.get('project_information') or {}
design = pi.get('design_number') or panel.design_number or '?'
ip_solved = ip_result is not None and not ip_result.refusal
wind_html = sec_in_place_wind(deck, panel)
overall, summary_html = build_summary(handling_results, ip_result,
ip_reason)
# Which ONE section opens by default alongside the Summary: the worst
# judged item, i.e. whichever governing ratio the overall verdict above
# actually came from. Nothing opens beyond the Summary on a NOT JUDGED
# deck -- there is no governing ratio to point at.
_RANK = {'FAIL': 3, 'WARN': 2, 'PASS': 1}
gov_key = None # 'handling:' | 'strength' | 'service'
gov_rank = -1
gov_util = -1.0
for i, hr in enumerate(handling_results):
if hr['verdict'] and _RANK[hr['verdict']] >= gov_rank:
util = hr['gov'][1]
if (_RANK[hr['verdict']] > gov_rank or util > gov_util):
gov_key, gov_rank, gov_util = ('handling:{0}'.format(i),
_RANK[hr['verdict']], util)
if ip_result is not None and not ip_result.refusal and \
ip_result.gov_util is not None:
v = _verdict(ip_result.gov_util)
if v and (_RANK[v] > gov_rank or
(_RANK[v] == gov_rank and ip_result.gov_util > gov_util)):
gov_key, gov_rank, gov_util = ('strength', _RANK[v],
ip_result.gov_util)
svc = getattr(ip_result, 'service', None) if ip_result else None
if svc is not None:
svc_cands = [u for u in (svc.tension_util, svc.compression_util,
svc.deflection.util
if svc.deflection else None) if u is not None]
if svc_cands:
svc_util = max(svc_cands)
v = _verdict(svc_util)
if v and (_RANK[v] > gov_rank or
(_RANK[v] == gov_rank and svc_util > gov_util)):
gov_key, gov_rank, gov_util = ('service', _RANK[v], svc_util)
sections = [
('General / Project Information', sec_analysis_general(deck, panel),
'', False),
('Concrete Extents', sec_extents(panel, None, readonly=True), '',
False),
('Materials', sec_materials(panel, None, None, readonly=True), '',
False),
('Prestress / Reinforcement',
sec_prestress(panel, None, None, readonly=True) +
sec_mild_bars(deck, readonly=True), '', False),
('Structural Model (supports, restraints, bearing)',
sec_analysis_structural_model(deck), '', False),
('Settings', sec_analysis_settings(deck), '', False),
('Loading Summary', sec_loading(panel, readonly=True), '', False),
('Section Properties', sec_section_properties(panel), '', False),
]
for i, hr in enumerate(handling_results):
sections.append((hr['title'], hr['body'], hr['tag'],
gov_key == 'handling:{0}'.format(i)))
sections.append(('In-Place Wind -- Zones (as entered)',
sec_wind_zones(deck), '', False))
if wind_html is not None:
sections.append(('In-Place Wind (ASCE C&C, computed)', wind_html,
'', False))
strength_tag = ''
service_tag = ''
if ip_result is not None and not ip_result.refusal:
v = _verdict(ip_result.gov_util)
if v is not None:
strength_tag = _sec_tag(ip_result.gov_check or '?',
ip_result.gov_util,
ip_result.gov_x or 0.0, v)
if svc is not None:
svc_cands = [('tension', svc.tension_util, svc.max_tension_x),
('compression', svc.compression_util,
svc.max_compression_x)]
if svc.deflection is not None and svc.deflection.util is not None:
svc_cands.append(('deflection', svc.deflection.util, None))
svc_cands = [c for c in svc_cands if c[1] is not None]
if svc_cands:
name, util, x = max(svc_cands, key=lambda c: c[1])
v = _verdict(util)
if v is not None:
service_tag = ('{1} {2}{3} '
'— {0} '.format(
v, name, R.n(util, 2),
' at {0}'.format(R.fmt_ft_in(x))
if x is not None else ''))
sections += [
('In-Place Load Case Results' if ip_solved else
'In-Place Load Case (not computed)',
sec_in_place_load_cases(ip_result, ip_reason), '', False),
('In-Place Load Combination Results' if ip_solved else
'In-Place Load Combination (not computed)',
sec_in_place_combinations(ip_result, ip_reason), '', False),
('In-Place Strength Summary' if ip_solved else
'In-Place Strength (not computed)',
sec_in_place_strength(ip_result, ip_reason), strength_tag,
gov_key == 'strength'),
('In-Place Service Summary' if ip_solved else
'In-Place Service (not computed)',
sec_in_place_service(ip_result, ip_reason), service_tag,
gov_key == 'service'),
('Everything this tool does not compute yet',
sec_analysis_placeholders(), '', False),
]
# `project_name` / `client_name` / `job_number` reach the deck via
# `panelanalysisform.build_deck` (2026-09 fix, alongside this redesign --
# that dict never carried them through before, even though the form
# collects all three); `location` / `project_location` have no source on
# the form at all today and stay conditional for whenever one exists.
project = pi.get('project_name') or pi.get('project') or None
location = pi.get('location') or pi.get('project_location') or None
client = pi.get('client_name') or None
job_no = pi.get('job_number') or None
sub_bits = ['Design {0}'.format(R.esc(design)),
'generated {0}'.format(
datetime.datetime.now().strftime('%Y-%m-%d %H:%M'))]
proj_bits = [str(b) for b in (project, location) if b]
if proj_bits:
sub_bits.append(R.esc(', '.join(proj_bits)))
if client:
sub_bits.append('client {0}'.format(R.esc(str(client))))
if job_no:
sub_bits.append('job {0}'.format(R.esc(str(job_no))))
body = ('{mark} — panel analysis \n'
' {sub}
\n'.format(
mark=R.esc(panel.mark),
sub=' · '.join(sub_bits))
+ summary_html
+ ''.join(_dsec(title, html, tag, open_)
for title, html, tag, open_ in sections))
return (' \n{mark} panel analysis \n'
'\n\n{body}\n \n'
'{hover}'.format(
mark=R.esc(panel.mark), css=_report_css(), body=body,
hover=R.hover_script()))
# --------------------------------------------------------------------- page
def build_page(deck_path, spec_override=None):
with io.open(deck_path, 'r', encoding='utf-8') as fh:
job = json.load(fh)
if (job.get('schema') or '').startswith('panelanalysis/'):
return build_analysis_page(job)
panel = build_panel(job)
job_root = resolve_job_root(deck_path)
spec, spec_path, design, spec_reason = load_spec(job, job_root,
spec_override)
know, layers, know_error = resolve_knowledge(job_root)
sections = [
('General / Project Information',
sec_general(job, panel, spec, spec_path, design, spec_reason,
know)),
('Concrete Extents', sec_extents(panel, spec)),
('Materials', sec_materials(panel, spec, know)),
('Prestress / Reinforcement', sec_prestress(panel, spec, know)),
('Structural Model (supports, bracing, connections)',
sec_structural_model(job, panel)),
('Settings', sec_settings(panel, know)),
('Loading Summary', sec_loading(panel)),
('Section Properties', sec_section_properties(panel)),
('Results -- Flexure & Shear Capacity', sec_capacity(panel)),
('In-Place Strength / Service (not computed)',
'Not computed -- needs the load-case '
'engine. See the placeholder list below.
'),
('Horizontal Shear Analysis (not computed)',
'Not computed -- needs both the '
'load-case engine and a read connector layout '
'(source.connectors, see Structural Model).
'),
('Everything this demo does not compute yet', sec_placeholders()),
]
banner = (
'This page is an input form, not a '
'report. Every field is an <input>, '
'pre-filled where a real source supplied a value and left empty '
'where nothing did -- a chip beside each one names that source. '
'Editing a field recomputes nothing on this page; it only '
'flips the chip to EDITED. That is the demo: the analysis pipeline '
'behind this form is the next phase, not something faked here.'
'
')
legend = (
'' + ' '.join(
'{0} {1}'.format(chip(s), {
'deck': 'the frozen model export',
'spec': 'the design extract (spec YAML)',
'knowledge': 'current merged plant/job knowledge',
'computed': "this tool's own arithmetic",
'absent': 'looked for, not found',
'placeholder': 'not computed -- no engine yet',
'planned': 'named future input, not built',
}[s]) for s in ('deck', 'spec', 'knowledge', 'computed', 'absent',
'placeholder', 'planned')) + '
')
if know_error:
legend += ('Knowledge layers could not be '
'resolved ({0}); every KNOWLEDGE field above fell back '
'to this tool\'s own default.
'.format(
R.esc(know_error)))
body = ('{mark} — panel analysis (demo) \n'
'Design {design} · generated {now}
\n'
'{banner}{legend}\n'.format(
mark=R.esc(panel.mark), design=R.esc(design or '?'),
now=datetime.datetime.now().strftime('%Y-%m-%d %H:%M'),
banner=banner, legend=legend)
+ ''.join(R._sec(title, html) for title, html in sections))
return (' \n{mark} panel analysis \n'
'\n\n{body}\n \n'
'{hover}'.format(
mark=R.esc(panel.mark), css=R.CSS, extra=EXTRA_CSS, body=body,
hover=R.hover_script(), js=EDIT_JS))
# ------------------------------------------------------------------- CLI
def main(argv=None):
ap = argparse.ArgumentParser(
description='Render a panel-analysis demo page for one job deck.')
ap.add_argument('deck', help='path to a *.job.json deck')
ap.add_argument('-o', '--out', required=True, help='output .html path')
ap.add_argument('--spec', default=None,
help='design-extract YAML to use instead of the '
'auto-located one')
ap.add_argument('--open', action='store_true',
help='open the rendered page in a browser')
args = ap.parse_args(argv)
html = build_page(args.deck, spec_override=args.spec)
with io.open(args.out, 'w', encoding='utf-8') as fh:
fh.write(html)
print('wrote {0}'.format(args.out))
if args.open:
webbrowser.open('file://' + os.path.abspath(args.out))
return 0
if __name__ == '__main__':
sys.exit(main())