Files
willtools-demo/tools/panelanalysis_schema.py
T
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at cbe45c5
(2026-09-09). Code, docs and public catalogue only; no plant or job
knowledge layers, no client or job names (tools/scrub.py clean). The
intro deck is left out of this snapshot because its slide images could
not be checked for job drawings.
2026-09-09 08:32:15 -05:00

1150 lines
51 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""The Panel Analysis deck schema -- `panelanalysis/1`, and how it maps.
WHY A SECOND SCHEMA. `panellift/1` (see `panellift.cli`'s docstring) is a
HANDLING job: a panel, a rig, and PCI Table 8.3.1 stages. Panel Analysis is a
different report -- Eriksson Wall's structural analysis of a wall panel -- and
it is organised the way Eriksson organises its own input, in named blocks, so
that later stages can fill each block in without re-shaping the file:
project_information mark, design number, materials (f'c / f'ci), losses.
settings analysis options: stress limits, resistance factors,
development multipliers. Seeded from knowledge in a
later stage; a Stage 0 fixture hand-authors them.
concrete_extents the panel as a section: length / width / thickness,
finish, product type, and the openings. Section
properties come off this at L/10, Eriksson's own
convention.
structural_model how the piece is held. `supports` is a list of
{x_ft, type} -- the dunnage / bearing lines the
Handling beam spans. `restraints` and `bearing` are
seeded from clash detection in a later stage (Stage
1); here they carry an explicit null + reason.
reinforcement `prestress`: the strands, exactly the shape
`panellift.cli.read_prestress` already reads.
loading `self_weight` is the only real load now (a factor and
an on/off); `wind` and `combinations` are the
in-place engine (Stage 3) and carry null + reason.
handling the Handling result block -- self-weight beam on the
`structural_model.supports`. Computed and rendered by
`panelanalysis.py`; in a deck it is an empty result
placeholder the tool fills.
in_place the In-Place result block -- panel on pin supports
under lateral + gravity. NOT built (Stage 3): an
explicit null + reason, never fabricated zeros.
source provenance. For a Stage 0 fixture it is synthetic and
says so.
THE FILE CONTRACT, unchanged from `panellift`: a field that was gathered but
not read is an EXPLICIT null carrying its reason, never a silent omission and
never a fabricated value. `unread(reason)` is the one shape those take --
`{"value": null, "reason": "..."}` -- so a reader (and a test) can tell "nobody
has wired this yet" apart from "this is genuinely empty". See
`willtools-error-patterns` / the whole of CLAUDE.md sec.3 on why a silent zero
is the cardinal sin here.
HOW IT REACHES THE ENGINE. Nothing in this file re-implements any mechanics.
`to_panel()` translates the blocks into the panel dict `panellift.cli.
read_panel` already validates, so every refusal that function makes (an empty
strand list, a strand with no pull, a bad number) is made here too, unchanged.
`handling_stage()` builds a `panellift.model.Stage` whose `supports` are the
`structural_model.supports` x-positions -- which is exactly the dunnage path
`panellift.statics.analyse` already solves as a continuous beam on rigid
supports. The support `type` (pinned / roller / fixed) is carried for the page
to show; the Handling solve treats every support as a rigid line, which is what
that path is.
"""
from __future__ import annotations
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
PANELLIFT_SRC = os.path.join(REPO, 'panellift', 'src')
if PANELLIFT_SRC not in sys.path:
sys.path.insert(0, PANELLIFT_SRC)
from panellift import cli as pl_cli # noqa: E402
from panellift import inplace # noqa: E402
from panellift import windload # noqa: E402
from panellift.model import Reinforcement, Rigging, Stage # noqa: E402
# The wind PRECEDENCE (direct > computed > blank) is the form's pure logic,
# host-tested in `vibe.panelanalysisform`; reuse it here rather than restating
# it. The `vibe` stub is already registered by `deckcheck` (imported first in
# `panelanalysis.py`) and by the test conftest, so this bare import resolves the
# Revit-free module the same way the tests do.
from vibe import panelanalysisform as _paf # noqa: E402
SCHEMA = 'panelanalysis/1'
# The blocks, in the order the page prints them. Named here so the tool and a
# test can agree on the set without either one hardcoding a list of strings.
BLOCKS = (
'project_information',
'settings',
'concrete_extents',
'structural_model',
'reinforcement',
'loading',
'handling',
'in_place',
'source',
)
# The default handling load factor. 1.0 is the bare self weight -- the Handling
# model is "the panel's own weight on its dunnage / bearing lines", and a stage
# that wants PCI Table 8.3.1's stripping/transport factor states it in
# `loading.self_weight.load_factor` rather than having one guessed here.
DEFAULT_SELF_WEIGHT_FACTOR = 1.0
class DeckError(ValueError):
"""The panelanalysis deck does not describe a panel that can be analysed.
A subclass of ValueError, so a caller that already catches the ValueError
`panellift` raises (the <2-supports refusal) catches this the same way.
"""
def unread(reason, value=None):
"""The one shape a gathered-but-unread field takes: null + why.
`{"value": None, "reason": "..."}`. A reader tells this apart from a field
nobody thought of (absent) and from a real empty value; a test asserts the
reason is present rather than that the key merely exists. `value` is kept
so a partially-known field can carry what little is known beside the reason,
but for Stage 0 it is None everywhere.
"""
return {'value': value, 'reason': str(reason)}
# ----------------------------------------------------------------- reading
def _block(deck, name):
block = deck.get(name)
if block is None:
return {}
if not isinstance(block, dict):
raise DeckError('block {0!r} must be a mapping, got {1!r}'
.format(name, block))
return block
def check_schema(deck):
"""Refuse a deck that is not this schema, by name. Absent is allowed.
A file with no `schema` key is taken as this one (the same leniency
`panellift` gives its own legacy files); a file naming a DIFFERENT schema
is refused rather than misread -- a `panellift/1` job has no
`structural_model` block and reading it here would silently analyse a piece
on no supports.
"""
schema = deck.get('schema')
if schema and schema != SCHEMA:
raise DeckError('deck says schema {0!r}; this reads {1!r}'
.format(schema, SCHEMA))
def to_panel(deck):
"""A `panellift.model.Panel` from the deck's blocks.
Built by translating the blocks into the panel dict `read_panel` already
validates, so every refusal that function makes is made here unchanged --
this file adds no second opinion about what a valid panel is.
"""
pi = _block(deck, 'project_information')
ce = _block(deck, 'concrete_extents')
rf = _block(deck, 'reinforcement')
panel_dict = {
'mark': pi.get('mark'),
'design_number': pi.get('design_number'),
'length_ft': ce.get('length_ft'),
'width_ft': ce.get('width_ft'),
'thickness_in': ce.get('thickness_in'),
'unit_weight_pcf': pi.get('unit_weight_pcf', 150.0),
'fc_psi': pi.get('fc_psi'),
'fci_psi': pi.get('fci_psi'),
'weight_lb': ce.get('weight_lb'),
'extra_cast_ft3': ce.get('extra_cast_ft3') or 0.0,
'finish': ce.get('finish') or 'smooth',
'product_type': ce.get('product_type') or 'flat',
'blockouts': ce.get('blockouts') or [],
'recesses': ce.get('recesses') or [],
# The Handling model here is a beam on dunnage supports, not a pick, so
# the panel carries no lifters -- the supports live on the stage.
'lifters': [],
'prestress': rf.get('prestress'),
'source': deck.get('source') or {},
}
try:
panel = pl_cli.read_panel(panel_dict)
except pl_cli.JobError as exc:
# Re-raise as our own type so a caller catches one exception, but keep
# the message -- it names the exact field `read_panel` refused.
raise DeckError(str(exc))
# Mild (non-prestressed) reinforcement, so the flexure check has a mild
# capacity path for a rebar wall panel. `read_panel` does not read it --
# `panellift.cli` is strand-only by construction and a test pins that -- so
# the deck's mild layers are attached here, onto `Panel.reinforcement`
# (which the model already carries and `capacity.mild_flexure_at` reads).
# The strand path is untouched; a pure-strand or bare deck gets [].
panel.reinforcement = mild_reinforcement(deck) + mild_bar_reinforcement(deck)
return panel
def mild_reinforcement(deck):
"""[`model.Reinforcement`] -- the deck's mild bar / mesh layers, or [].
Read off `reinforcement.mild`, a list of layers each
`{area_in2, z_in, grade_ksi, count?, kind?, label?}`. `area_in2` is the
area of ONE bar and `count` how many are in the layer (total = area*count,
the shape `model.Reinforcement` already uses); `z_in` is the layer's depth
up from the form face, the same axis the strands use, so `capacity` can
compare it against a neutral axis directly. `grade_ksi` is the yield f_y.
A missing block is a piece with no mild steel and returns [] -- NOT an
error, the ordinary case for a prestressed piece. A layer missing a
required number is refused loudly, the same discipline `read_panel` uses,
rather than silently dropped (a dropped tension layer overstates nothing
but understates capacity, which is a FAIL the section does not have).
"""
rf = _block(deck, 'reinforcement')
items = rf.get('mild')
if items is None:
return []
if not isinstance(items, list):
raise DeckError('reinforcement.mild must be a list of layers, got '
'{0!r}'.format(items))
out = []
for i, r in enumerate(items):
at = 'reinforcement.mild[{0}]'.format(i)
if not isinstance(r, dict):
raise DeckError('{0} must be a mapping, got {1!r}'.format(at, r))
for key in ('area_in2', 'z_in', 'grade_ksi'):
if r.get(key) is None:
raise DeckError('{0} has no {1!r}; a mild layer needs area_in2 '
'(per bar), z_in (depth up from the form face) '
'and grade_ksi (f_y)'.format(at, key))
try:
out.append(Reinforcement(
kind=str(r.get('kind') or 'bar'),
label=str(r.get('label') or 'M{0}'.format(i + 1)),
area_in2=float(r['area_in2']),
z_in=float(r['z_in']),
grade_ksi=float(r['grade_ksi']),
count=int(r.get('count', 1))))
except (TypeError, ValueError) as exc:
raise DeckError('{0}: a mild-layer field is not a number ({1})'
.format(at, exc))
return out
#: `settings.rebar_fy_ksi`'s default -- Grade 60, `vibe.panelanalysisform`'s
#: own default for the same field ('60'). `reinforcement.mild_bars` carries no
#: per-bar grade (PANELANALYSIS-4's shape names size/count/area/depth/
#: direction/from/to only), so every bar in that block shares this ONE deck-
#: wide setting rather than each guessing its own.
DEFAULT_MILD_BAR_GRADE_KSI = 60.0
def mild_bar_grade_ksi(deck):
"""The f_y every `reinforcement.mild_bars` layer is credited at."""
return _settings_number(deck, 'rebar_fy_ksi', DEFAULT_MILD_BAR_GRADE_KSI)
def mild_bars(deck):
"""[{size, count, area_in2, depth_in, direction, from_ft, to_ft}] --
`reinforcement.mild_bars`, AS ENTERED, or [] for a deck with no block.
This is the raw per-row read the page prints (every row, whether or not
it ends up in the capacity); `mild_bar_reinforcement` is the narrower
filter that turns the eligible rows into `model.Reinforcement` layers.
`count` and `area_in2` (the layer's TOTAL area, already count-multiplied
-- the field model's own convention, unlike `reinforcement.mild`'s
per-bar `area_in2`) are required; `depth_in` may be null (a bar the
field model could not measure a depth for), `from_ft`/`to_ft` may be null
(present over the whole length) -- neither is refused, both travel as
None. `direction` defaults to 'Longitudinal' when a row omits it, the
common case for a deck built before this field existed on the producer
side.
"""
rf = _block(deck, 'reinforcement')
items = rf.get('mild_bars')
if items is None:
return []
if not isinstance(items, list):
raise DeckError('reinforcement.mild_bars must be a list of bar '
'layers, got {0!r}'.format(items))
out = []
for i, r in enumerate(items):
at = 'reinforcement.mild_bars[{0}]'.format(i)
if not isinstance(r, dict):
raise DeckError('{0} must be a mapping, got {1!r}'.format(at, r))
for key in ('count', 'area_in2'):
if r.get(key) is None:
raise DeckError('{0} has no {1!r}; a mild bar layer needs '
'count and area_in2 (the layer\'s TOTAL area)'
.format(at, key))
try:
out.append({
'size': str(r.get('size') or ''),
'count': int(r['count']),
'area_in2': float(r['area_in2']),
'depth_in': _num_or_none(r.get('depth_in')),
'direction': str(r.get('direction') or 'Longitudinal'),
'from_ft': _num_or_none(r.get('from_ft')),
'to_ft': _num_or_none(r.get('to_ft')),
})
except (TypeError, ValueError) as exc:
raise DeckError('{0}: a mild-bar field is not a number ({1})'
.format(at, exc))
return out
def mild_bar_reinforcement(deck):
"""[model.Reinforcement] -- the LONGITUDINAL `mild_bars` rows WITH a
depth, ready for `capacity.mild_flexure_at`'s strand-and-mild strain
compatibility (it already combines strand + mild layers on
`panel.reinforcement`; this only has to populate that list correctly).
A row is excluded (contributes nothing here) when EITHER:
* `direction` is not 'Longitudinal' -- a Transverse bar is listed on
the page but never enters the out-of-plane flexural capacity, the
wrong axis for it to help this check;
* `depth_in` is None -- the field model could not measure one, and a
capacity is never assumed from an unmeasured depth. The page reports
this row as "not in the capacity (no depth given)"; `mild_bars`
carries it, this function does not.
A deck with no `reinforcement.mild_bars` block at all (every deck before
PANELANALYSIS-4, including the handling goldens) returns [] and changes
NOTHING -- `to_panel` concatenates this with `mild_reinforcement(deck)`,
and an empty list added to `panel.reinforcement` is the identity.
`area_in2` here is already the layer's TOTAL area (`mild_bars`'s own
convention), so it is carried straight across with `count=1` --
`Reinforcement.total_area_in2` is `area_in2 * count`, and `count=1`
keeps that total exactly what the deck stated rather than multiplying it
a second time by the bar count.
"""
fy = mild_bar_grade_ksi(deck)
out = []
for i, b in enumerate(mild_bars(deck)):
if b['direction'] != 'Longitudinal' or b['depth_in'] is None:
continue
out.append(Reinforcement(
kind='bar', label=b['size'] or 'MB{0}'.format(i + 1),
area_in2=b['area_in2'], z_in=b['depth_in'], grade_ksi=fy,
count=1, from_ft=b['from_ft'], to_ft=b['to_ft']))
return out
def supports(deck):
"""[(x_ft, type)], in the order written. The Handling beam's supports.
`type` is carried for the page; the solve treats every one as a rigid
line. A missing/short list is NOT refused here -- the refusal belongs to
the solve, which raises `panellift`'s own "a supported stage needs at least
two supports" so the page can surface that exact sentence as NOT JUDGED
rather than this file inventing a parallel message.
"""
sm = _block(deck, 'structural_model')
out = []
for i, s in enumerate(sm.get('supports') or []):
if not isinstance(s, dict):
raise DeckError('structural_model.supports[{0}] must be a mapping '
'with an "x_ft", got {1!r}'.format(i, s))
if s.get('x_ft') is None:
raise DeckError('structural_model.supports[{0}] has no "x_ft"'
.format(i))
try:
x = float(s['x_ft'])
except (TypeError, ValueError):
raise DeckError('structural_model.supports[{0}].x_ft is not a '
'number ({1!r})'.format(i, s['x_ft']))
out.append((x, str(s.get('type') or 'support')))
return out
def self_weight_factor(deck):
"""The Handling load factor from `loading.self_weight`, or the default."""
sw = (_block(deck, 'loading').get('self_weight')) or {}
if not isinstance(sw, dict):
return DEFAULT_SELF_WEIGHT_FACTOR
factor = sw.get('load_factor')
if factor is None:
return DEFAULT_SELF_WEIGHT_FACTOR
try:
return float(factor)
except (TypeError, ValueError):
raise DeckError('loading.self_weight.load_factor is not a number '
'({0!r})'.format(factor))
def handling_fc_psi(panel):
"""The concrete strength the Handling model is checked at.
f'ci -- the release strength -- when the deck states one, because handling
(stripping, yard) happens on young concrete and that is the conservative
of the two. f'c otherwise. Returned separately from the panel so the caller
can label the page with which one it used, and so the capacity table can
also run the 28-day strength beside it.
"""
return panel.fci_psi or panel.fc_psi or 0.0
# The stress limits are a JOB decision resolved through the knowledge layers
# (see `Stage.tension_limit_sqrt_fc` / `.compression_limit_ratio`), and the
# deck carries whatever they resolved to in its `settings` block. Until now
# nothing read them; the Handling solve built a Stage with the panellift
# DEFAULTS (5.0, 0.6) even when the deck plainly stated otherwise. These pull
# them off `settings`, falling back to the panellift default when the deck is
# silent -- so a deck with no settings behaves exactly as before.
DEFAULT_TENSION_LIMIT_SQRT_FC = 5.0
DEFAULT_COMPRESSION_LIMIT_RATIO = 0.6
def _settings_number(deck, key, default):
st = _block(deck, 'settings').get(key)
if st is None:
return default
try:
return float(st)
except (TypeError, ValueError):
raise DeckError('settings.{0} is not a number ({1!r})'.format(key, st))
def tension_limit_sqrt_fc(deck):
return _settings_number(deck, 'tension_limit_sqrt_fc',
DEFAULT_TENSION_LIMIT_SQRT_FC)
def compression_limit_ratio(deck):
return _settings_number(deck, 'compression_limit_ratio',
DEFAULT_COMPRESSION_LIMIT_RATIO)
def development_length_multiplier(deck):
"""Eriksson's 'Development Multipliers' setting -- 1.0 unless stated.
It lengthens the transfer/development region the phi ramp reads across, so
a deck that carries 1.3 holds phi below 0.90 further from the piece ends.
"""
return _settings_number(deck, 'development_length_multiplier', 1.0)
def _stage(deck, panel, name, pci_case, multiplier, fc_psi):
"""A supported Stage carrying THIS deck's stress limits, not the defaults.
`tension_limit_source` / `compression_limit_source` are 'job' when the deck
stated a limit and 'default' when it did not, exactly the provenance
`Stage` documents -- so the page can say who set the allowable it printed.
"""
st = _block(deck, 'settings')
xs = [x for x, _type in supports(deck)]
return Stage(
name=name, multiplier=multiplier, fc_psi=fc_psi, pci_case=pci_case,
supports=xs,
tension_limit_sqrt_fc=tension_limit_sqrt_fc(deck),
tension_limit_source=('job' if st.get('tension_limit_sqrt_fc')
is not None else 'default'),
compression_limit_ratio=compression_limit_ratio(deck),
compression_limit_source=('job' if st.get('compression_limit_ratio')
is not None else 'default'))
def handling_stage(deck, panel):
"""A `panellift.model.Stage` for the self-weight Handling solve.
`supports` set means `panellift.statics.analyse` runs the dunnage path: a
continuous beam on rigid supports, self weight only. The x-positions come
straight off `structural_model.supports`; nothing else about the stage
matters to the solve except `fc_psi`, which sets the elastic modulus for
the (indeterminate, 3+-support) load distribution and the strength the
capacity columns are measured at.
The multiplier is `loading.self_weight.load_factor` (the deck's own
stripping factor), the strength is f'ci, and the stress limits are the
deck's settings -- see `_stage`. Kept as the single-stage entry point the
page's Handling section and the parity harness already call; the two-stage
default is `handling_stages`.
"""
return _stage(deck, panel, name='handling (self weight)',
pci_case='stripping', multiplier=self_weight_factor(deck),
fc_psi=handling_fc_psi(panel))
# PCI/Eriksson default handling stages (docs/eriksson-wall-algorithms.md sec.7,
# QC p.133 `gamma_strip=1.4, gamma_transport=1.5, gamma_erection=1.2`):
# stripping at the release strength f'ci with the stripping dynamic factor,
# transportation AND erection at the 28-day strength f'c with their own
# factors. Overridable per deck from the `handling` block -- a `handling.stages`
# list of {name, pci_case, multiplier, strength} entries, or
# `handling.stripping_multiplier` / `.transport_multiplier` / `.erection_
# multiplier` to nudge just the factors. Defaults per the reference tool.
DEFAULT_STRIPPING_MULTIPLIER = 1.4
DEFAULT_TRANSPORT_MULTIPLIER = 1.5
DEFAULT_ERECTION_MULTIPLIER = 1.2
def _strength_for(panel, which):
"""'release' -> f'ci (falling back to f'c); '28day'/'service' -> f'c."""
if which in ('release', 'stripping', 'fci'):
return panel.fci_psi or panel.fc_psi or 0.0
return panel.fc_psi or panel.fci_psi or 0.0
def handling_stages(deck, panel):
"""The handling stages to run, each a `Stage` carrying the deck's limits.
Default is the two Eriksson runs -- Stripping x1.4 at f'ci, Transportation
x1.5 at f'c -- and the `loading.self_weight.load_factor`, if the deck
states one, overrides the stripping multiplier so a deck hand-tuned for one
factor still drives it. A `handling.stages` list replaces the defaults
outright; `handling.stripping_multiplier` / `handling.transport_multiplier`
nudge just those numbers.
A stage whose strength comes out 0 (a deck stating neither f'c nor f'ci) is
dropped rather than run at no strength -- the same refuse-loudly discipline
the solve uses, one layer earlier.
"""
handling = _block(deck, 'handling')
out = []
override = handling.get('stages')
if override is not None:
if not isinstance(override, list):
raise DeckError('handling.stages must be a list, got {0!r}'
.format(override))
for i, s in enumerate(override):
if not isinstance(s, dict):
raise DeckError('handling.stages[{0}] must be a mapping, got '
'{1!r}'.format(i, s))
fc = _strength_for(panel, s.get('strength') or 'release')
if not fc:
continue
out.append(_stage(
deck, panel, name=s.get('name') or 'stage {0}'.format(i + 1),
pci_case=s.get('pci_case') or 'stripping',
multiplier=float(s.get('multiplier', DEFAULT_STRIPPING_MULTIPLIER)),
fc_psi=fc))
return out
# A deck-stated self-weight factor is the stripping factor the page has
# always driven; keep honouring it so nothing regresses.
sw = self_weight_factor(deck)
strip_mult = handling.get('stripping_multiplier')
strip_mult = (float(strip_mult) if strip_mult is not None
else (sw if sw != DEFAULT_SELF_WEIGHT_FACTOR
else DEFAULT_STRIPPING_MULTIPLIER))
trans_mult = handling.get('transport_multiplier')
trans_mult = (float(trans_mult) if trans_mult is not None
else DEFAULT_TRANSPORT_MULTIPLIER)
erect_mult = handling.get('erection_multiplier')
erect_mult = (float(erect_mult) if erect_mult is not None
else DEFAULT_ERECTION_MULTIPLIER)
fci = _strength_for(panel, 'release')
if fci:
out.append(_stage(deck, panel, name='Stripping', pci_case='stripping',
multiplier=strip_mult, fc_psi=fci))
fc = _strength_for(panel, '28day')
if fc:
out.append(_stage(deck, panel, name='Transportation',
pci_case='transportation', multiplier=trans_mult,
fc_psi=fc))
out.append(_stage(deck, panel, name='Erection',
pci_case='erection', multiplier=erect_mult,
fc_psi=fc))
return out
def handling_rigging():
"""The rigging object the dunnage path needs but does not read.
`statics.analyse` takes a `Rigging`; on the supports path it is only
stored on the returned `Analysis` (no cable groups, no sling angle). Static
mode names what a trailer/dunnage actually is.
"""
return Rigging(mode='static')
# ------------------------------------------------------------- in-place (stage3)
#
# The In-Place model inputs -- lateral restraints, wind pressures, applied axial
# loads and the load combinations -- read off the deck's structural_model and
# loading blocks. Every one is OPTIONAL and STAGE-3 tagged: a deck that has not
# had them wired carries the `unread(...)` shape (value null + reason) these
# readers already understand, and the page keeps its explicit placeholder rather
# than fabricating an in-place result. The handling path is untouched.
def _is_unread(block):
"""True for the `{value: None, reason: ...}` gathered-but-unread shape."""
return (isinstance(block, dict) and 'reason' in block
and block.get('value') is None and 'x_ft' not in block
and 'zones' not in block)
# A restraint's `type` naming 'Free' is NOT a lateral restraint at all -- the
# field model's own vocabulary (`vibe.panelanalysisform`'s
# `restraint_foundation_connection`) carries it beside 'Pin' / 'Fixed', and a
# 'Floor tie' the same producer may also emit. Matched case-insensitively so a
# stray capitalisation does not silently un-skip a free end; every OTHER type
# word (including the golden's own 'restrained', 'dunnage', or anything this
# reader has never seen) is treated as a lateral restraint -- unrecognised
# never means "exclude" here, only 'Free' by NAME does (CLAUDE.md sec.3's
# "unrecognised state renders red, never grey", the same discipline in
# reverse: an unknown type is COUNTED, not quietly dropped).
def _is_free_restraint(type_word):
return str(type_word or '').strip().lower() == 'free'
def restraint_rows(deck):
"""[{x_ft, type, elevation_ft, gravity, thermal, stiffness_kin, lateral}]
-- every row of `structural_model.restraints`, AS ENTERED, for the page.
Tolerates every extra key the field model now carries (`elevation_ft`,
`gravity`, `thermal`, `stiffness_kin`) by simply passing each one through
-- absent keys come back None, never invented. `lateral` is False only for
a 'Free' type (see `_is_free_restraint`); every other row is True, whether
or not this reader recognises the word. [] for a missing/unread block or
one that is not a list -- the caller (`restraints()`) owns refusing a
malformed block; this is display-only and never raises.
"""
sm = _block(deck, 'structural_model')
blk = sm.get('restraints')
if blk is None or _is_unread(blk):
return []
items = blk.get('value') if isinstance(blk, dict) and 'value' in blk else blk
if not isinstance(items, list):
return []
out = []
for r in items:
if not isinstance(r, dict):
continue
typ = r.get('type')
out.append({
'x_ft': r.get('x_ft'),
'type': typ,
'elevation_ft': r.get('elevation_ft'),
'gravity': r.get('gravity'),
'thermal': r.get('thermal'),
'stiffness_kin': r.get('stiffness_kin'),
'lateral': not _is_free_restraint(typ),
})
return out
def restraints(deck):
"""([x_ft], reason) -- the LATERAL restraints the in-place panel spans.
A list of {x_ft, type, ...} under `structural_model.restraints`, OR the
`unread(...)` placeholder (-> [], reason). A row whose `type` is 'Free'
(`_is_free_restraint`) is not a lateral restraint at all and is skipped --
every other type word (recognised or not) counts. The in-place solve
refuses fewer than two the same way the handling solve does; this reader
does not second-guess that refusal, it only parses and filters by type.
"""
sm = _block(deck, 'structural_model')
blk = sm.get('restraints')
if blk is None:
return [], 'no structural_model.restraints on this deck'
if _is_unread(blk):
return [], blk.get('reason') or 'restraints not wired yet'
items = blk.get('value') if isinstance(blk, dict) and 'value' in blk else blk
if not isinstance(items, list):
raise DeckError('structural_model.restraints must be a list of '
'{{x_ft, type}} or an unread placeholder, got {0!r}'
.format(blk))
out = []
for i, r in enumerate(items):
if not isinstance(r, dict) or r.get('x_ft') is None:
raise DeckError('structural_model.restraints[{0}] needs an "x_ft"'
.format(i))
if _is_free_restraint(r.get('type')):
continue
try:
out.append(float(r['x_ft']))
except (TypeError, ValueError):
raise DeckError('structural_model.restraints[{0}].x_ft is not a '
'number ({1!r})'.format(i, r['x_ft']))
return out, None
def compute_asce_pressures(deck):
"""(windload.WindResult, reason). Runs `windload.compute` off loading.wind.asce.
(None, reason) when there is nothing to compute from -- no `asce` block, or
one missing the numeric inputs `windload.compute` cannot run without (Vult,
mean roof height, effective wind area). A WindResult whose `.not_judged` is
set is returned AS the result (h>60, un-mined ASCE version, bad selectors):
the reason travels on it, never fabricated into a pressure. This is the
CPython deck-solve step that owns the ASCE arithmetic -- `windload` is
CPython and cannot be reached from the IronPython form.
"""
blk = _block(deck, 'loading').get('wind')
if not isinstance(blk, dict):
return None, 'no loading.wind block'
a = blk.get('asce')
if not isinstance(a, dict):
return None, (blk.get('asce_incomplete')
or 'no loading.wind.asce inputs to compute from')
def _f(key):
v = a.get(key)
try:
return float(v) if v is not None and v != '' else None
except (TypeError, ValueError):
return None
v_ult = _f('v_ult_mph')
h = _f('mean_roof_height_ft')
area = _f('effective_wind_area_ft2')
if v_ult is None or h is None or area is None:
return None, ('loading.wind.asce needs numeric v_ult_mph, '
'mean_roof_height_ft and effective_wind_area_ft2')
cc = a.get('cc_chapter')
try:
cc_chapter = (int(float(cc)) if cc not in (None, '')
else windload.CC_CHAPTER)
except (TypeError, ValueError):
cc_chapter = windload.CC_CHAPTER
kzt = _f('kzt')
res = windload.compute(
v_ult_mph=v_ult,
risk_category=a.get('risk_category'),
exposure=a.get('exposure'),
enclosure=a.get('enclosure'),
code_version=a.get('code_version'),
mean_roof_height_ft=h,
effective_wind_area_ft2=area,
cc_chapter=cc_chapter,
kzt=kzt if kzt is not None else 1.0)
return res, None
def _governing_pressures(res):
"""(governing_mag, positive_psf, negative_psf, uncertainty_psf) across the
C&C zones, or None where the result is not_judged / empty.
The in-place model runs a single out-of-plane pressure; a straight wall
bends both ways, so the GOVERNING magnitude is what matters -- the largest
|p| over Zones 4/5, positive or negative (suction).
"""
if res is None or res.not_judged or not res.zones:
return None
pos = max(z.positive_psf for z in res.zones)
neg = min(z.negative_psf for z in res.zones) # most negative
unc = max(z.uncertainty_psf for z in res.zones)
return max(abs(pos), abs(neg)), pos, neg, unc
def resolve_wind(deck):
"""The in-place wind-pressure DECISION + provenance, or None.
None when `loading.wind` is absent/unread, or carries an explicit `zones`
list (the golden given-pressure path, handled by `wind_zones` directly).
Otherwise a dict from `panelanalysisform.resolve_wind_pressure` (the pure
direct > computed > blank precedence) enriched for the page:
source 'direct' | 'computed' | 'not_judged' | 'blank'
pressure_psf governing magnitude fed to the in-place wind case
positive_psf, the C&C design values, for display (computed path)
negative_psf
uncertainty_psf +/- band from the figure-read GCp (computed path)
cite, gcpi, windload provenance (computed path)
gcpi_cite
result the windload.WindResult (computed path) or None
reason not-judged / blank reason (else None)
"""
blk = _block(deck, 'loading').get('wind')
if not isinstance(blk, dict) or _is_unread(blk) or 'zones' in blk:
return None
if blk.get('mode') == 'project_config':
# Not built (PANELANALYSIS-4): `wind_zones` already returns this
# deck's own refusal; nothing here should try an ASCE/direct-entry
# resolution behind it and print a DIFFERENT (misleading) answer.
return None
direct_p = blk.get('direct_pressure_psf')
direct_s = blk.get('direct_suction_psf')
dvals = []
for v in (direct_p, direct_s):
if v is not None:
try:
dvals.append(abs(float(v)))
except (TypeError, ValueError):
pass
direct_mag = max(dvals) if dvals else None
res, reason = compute_asce_pressures(deck)
gov = _governing_pressures(res)
computed_mag = gov[0] if gov else None
computed_reason = None
if res is not None and res.not_judged:
computed_reason = res.not_judged
elif computed_mag is None and reason:
computed_reason = reason
info = dict(_paf.resolve_wind_pressure(direct_mag, computed_mag,
computed_reason))
info['result'] = res
info['positive_psf'] = gov[1] if gov else None
info['negative_psf'] = gov[2] if gov else None
info['uncertainty_psf'] = gov[3] if gov else None
info['direct_pressure_psf'] = _num_or_none(direct_p)
info['direct_suction_psf'] = _num_or_none(direct_s)
if res is not None and res.gcpi is not None:
info['gcpi'], info['gcpi_cite'] = res.gcpi.value, res.gcpi.cite
else:
info['gcpi'], info['gcpi_cite'] = None, None
info['cite'] = res.zones[0].basis if (res is not None and res.zones) else None
return info
def _num_or_none(v):
if v is None:
return None
try:
return float(v)
except (TypeError, ValueError):
return None
#: `wind_zones`'s fixed reason for `mode: 'project_config'` -- named here so
#: the page and a test can assert the exact sentence rather than a substring
#: guess. PANELANALYSIS-4: "wind from project config" is a real future mode
#: (a Project Config wind-speed/exposure module), not built yet.
WIND_PROJECT_CONFIG_NOT_BUILT = 'wind from project config is not built yet'
def wind_zones(deck, panel=None):
"""([LateralPressureZone], tributary_width_ft, reason).
FOUR ways a deck states the out-of-plane wind, checked by `loading.wind.
mode` first and falling back to the pre-existing shape-sniffing for a deck
with no `mode` at all (every deck built before PANELANALYSIS-4):
* `mode: 'manual'` -- an explicit `zones` list, each carrying its OWN
`pressure_psf` and/or `suction_psf` (either may be blank -> None,
meaning that direction is simply absent for that zone) and its own
`width_ft` (None -> the panel's tributary width). This is the new
manual per-zone contract; `panellift.inplace` reads `suction_psf` off
these same zone objects to build an independent W- field
(`build_wind_suction_cases`) rather than assuming symmetry.
* `mode: 'project_config'` -- NOT built: ([], trib,
`WIND_PROJECT_CONFIG_NOT_BUILT`), always, regardless of any other key
the block carries. Never fabricated from whatever else happens to be
present.
* no `mode` (legacy) -- exactly the pre-existing two paths, UNCHANGED:
an explicit `zones` list -- {x0_ft, x1_ft, pressure_psf, label}, one
magnitude per zone, no suction -- the GIVEN-pressure path the goldens
and fixtures use; or the ASCE inputs (`asce`) and/or a direct-entry
pressure, which `resolve_wind` turns into ONE governing pressure over
the panel length (direct > computed > blank).
* the `unread(...)` placeholder -> ([], None, reason).
A legacy zone's `LateralPressureZone` carries `suction_psf=None` (the
dataclass default) -- `inplace.InPlaceInputs.wind_zone_mode` (set by
`in_place_inputs`, off this same `mode` key) is what keeps those decks on
the historic negate-the-whole-field suction shortcut; this reader does not
need to say so itself.
"""
blk = _block(deck, 'loading').get('wind')
if blk is None:
return [], None, 'no loading.wind on this deck'
if _is_unread(blk):
return [], None, blk.get('reason') or 'wind not wired yet'
mode = blk.get('mode') if isinstance(blk, dict) else None
if mode == 'project_config':
trib = blk.get('tributary_width_ft')
trib = float(trib) if trib is not None else None
return [], trib, WIND_PROJECT_CONFIG_NOT_BUILT
trib = blk.get('tributary_width_ft')
trib = float(trib) if trib is not None else None
if mode == 'manual':
zdefs = blk.get('zones')
if not isinstance(zdefs, list):
raise DeckError('loading.wind.zones must be a list, got {0!r}'
.format(zdefs))
zones = []
for i, z in enumerate(zdefs):
if not isinstance(z, dict):
raise DeckError('loading.wind.zones[{0}] must be a mapping, '
'got {1!r}'.format(i, z))
try:
x0 = float(z['x0_ft'])
x1 = float(z['x1_ft'])
except (KeyError, TypeError, ValueError) as exc:
raise DeckError('loading.wind.zones[{0}] needs numeric '
'x0_ft, x1_ft ({1})'.format(i, exc))
width = z.get('width_ft')
width = _num_or_none(width)
pressure = _num_or_none(z.get('pressure_psf'))
suction = _num_or_none(z.get('suction_psf'))
zones.append(inplace.LateralPressureZone(
x0_ft=x0, x1_ft=x1, pressure_psf=pressure,
suction_psf=suction, width_ft=width,
label=str(z.get('label') or 'W{0}'.format(i + 1))))
return zones, trib, None
if isinstance(blk, dict) and 'zones' in blk:
zdefs = blk.get('zones')
if not isinstance(zdefs, list):
raise DeckError('loading.wind.zones must be a list, got {0!r}'
.format(zdefs))
zones = []
for i, z in enumerate(zdefs):
try:
zones.append(inplace.LateralPressureZone(
x0_ft=float(z['x0_ft']), x1_ft=float(z['x1_ft']),
pressure_psf=float(z['pressure_psf']),
label=str(z.get('label') or 'W{0}'.format(i + 1))))
except (KeyError, TypeError, ValueError) as exc:
raise DeckError('loading.wind.zones[{0}] needs numeric x0_ft, '
'x1_ft, pressure_psf ({1})'.format(i, exc))
return zones, trib, None
# ASCE-computed / direct-entry path.
info = resolve_wind(deck)
if info is None or info.get('pressure_psf') is None:
return [], trib, (info or {}).get('reason') or 'no wind pressure supplied'
length = getattr(panel, 'length_ft', None) if panel is not None else None
x1 = float(length) if length else 1.0
zone = inplace.LateralPressureZone(
x0_ft=0.0, x1_ft=x1, pressure_psf=float(info['pressure_psf']),
label='W-{0}'.format(info['source']))
return [zone], trib, None
def applied_axial(deck):
"""[AppliedAxial] -- concentrated gravity bearing loads, or [].
`ecc_in` is read directly, or as its alias `offset_in` -- the name
`panelanalysisform.parse_conc_loads`/`build_deck` actually writes on each
row (Eriksson's own "Offset" column). `ecc_in` wins if a row somehow
carries both. Neither present -> 0.0, a pure axial load, unchanged from
before this field existed.
"""
items = _block(deck, 'loading').get('applied_axial') or []
if not isinstance(items, list):
raise DeckError('loading.applied_axial must be a list, got {0!r}'
.format(items))
out = []
for i, a in enumerate(items):
try:
ecc = a.get('ecc_in')
if ecc is None:
ecc = a.get('offset_in')
out.append(inplace.AppliedAxial(
x_ft=float(a['x_ft']), dead_lb=float(a.get('dead_lb') or 0.0),
live_lb=float(a.get('live_lb') or 0.0),
label=str(a.get('label') or 'P{0}'.format(i + 1)),
ecc_in=float(ecc) if ecc is not None else 0.0))
except (KeyError, TypeError, ValueError) as exc:
raise DeckError('loading.applied_axial[{0}] needs a numeric x_ft '
'({1})'.format(i, exc))
return out
def service_options(deck):
"""The SERVICE-side inputs (Stage 3), from `loading.service`, or defaults.
A mapping {deflection_limit_ratio, cracking_class_t, initial_bow_in,
axial_eccentricity_in} -- or the `unread(...)` placeholder / absent, which
leaves each field at its could-not-judge default (deflection_limit_ratio
None => the deflection LIMIT check is NOT JUDGED, never faked). Numbers are
validated; a non-number is refused by name.
"""
blk = _block(deck, 'loading').get('service')
if blk is None or _is_unread(blk):
return {}
if not isinstance(blk, dict):
raise DeckError('loading.service must be an unread placeholder or a '
'mapping, got {0!r}'.format(blk))
out = {}
for key in ('deflection_limit_ratio', 'initial_bow_in',
'axial_eccentricity_in'):
if blk.get(key) is not None:
try:
out[key] = float(blk[key])
except (TypeError, ValueError):
raise DeckError('loading.service.{0} is not a number ({1!r})'
.format(key, blk[key]))
if 'cracking_class_t' in blk:
out['cracking_class_t'] = bool(blk['cracking_class_t'])
return out
def in_place_inputs(deck, panel):
"""(InPlaceInputs, reason). None+reason where the in-place model is unwired.
Requires at least the lateral restraints; wind may be absent (a gravity-only
in-place run is legitimate and the engine flags the zero lateral demand).
Returns (None, reason) whenever the restraints are unwired -- which is the
Stage-0 default -- so the page keeps its explicit placeholder.
"""
rxs, r_reason = restraints(deck)
if len(rxs) < 2:
return None, (r_reason or 'fewer than two lateral restraints')
zones, trib, _w_reason = wind_zones(deck, panel)
combos = None
combo_blk = _block(deck, 'loading').get('combinations')
if isinstance(combo_blk, list):
combos = _parse_combinations(combo_blk)
svc = service_options(deck)
return inplace.InPlaceInputs(
restraint_xs=rxs, wind_zones=zones,
applied_axial=applied_axial(deck), tributary_width_ft=trib,
combinations=combos, fc_psi=handling_fc_psi_inplace(panel),
deflection_limit_ratio=svc.get('deflection_limit_ratio'),
cracking_class_t=svc.get('cracking_class_t', False),
initial_bow_in=svc.get('initial_bow_in', 0.0) or 0.0,
axial_eccentricity_in=svc.get('axial_eccentricity_in', 0.0) or 0.0,
wind_zone_mode=wind_zone_mode(deck)), None
def wind_zone_mode(deck):
"""'manual' when the deck states `loading.wind.mode == 'manual'`, else
'legacy' -- the flag `inplace.InPlaceInputs.wind_zone_mode` reads to
choose between the new per-zone pressure/suction envelope and the
historic negate-the-field shortcut (see `wind_zones`'s own docstring).
"""
blk = _block(deck, 'loading').get('wind')
if isinstance(blk, dict) and blk.get('mode') == 'manual':
return 'manual'
return 'legacy'
def handling_fc_psi_inplace(panel):
"""f'c for the in-place model -- the 28-day strength (docs sec.6)."""
return panel.fc_psi or panel.fci_psi or 0.0
def _parse_combinations(items):
"""Deck-supplied load combinations, or None to take the ASCE default set."""
out = []
for i, c in enumerate(items):
if not isinstance(c, dict) or not isinstance(c.get('factors'), dict):
raise DeckError('loading.combinations[{0}] needs a "factors" map'
.format(i))
out.append(inplace.Combo(
name=str(c.get('name') or 'LC{0}'.format(i + 1)),
label=str(c.get('label') or ''),
factors={str(k): float(v) for k, v in c['factors'].items()},
kind=str(c.get('kind') or 'strength'),
cite=str(c.get('cite') or '')))
return out
# ----------------------------------------------------------------- writing
def new_deck(mark='PNL101', design_number='PNL1',
length_ft=24.0, width_ft=8.0, thickness_in=8.0,
fc_psi=6000.0, fci_psi=4000.0, unit_weight_pcf=150.0,
prestress_loss_pct=8.0, support_xs=(2.0, 22.0),
support_type='dunnage', self_weight_factor=1.0,
strands=None, blockouts=None, weight_lb=None,
tension_limit_sqrt_fc=5.0, compression_limit_ratio=0.6):
"""A well-formed `panelanalysis/1` deck, for fixtures and documentation.
Every unread block is written as an explicit null + reason via `unread()`,
so a deck this builds is a worked example of the file contract as much as a
test input. The defaults describe a plain solid panel on two dunnage lines
with two straight strands -- the simplest thing the Handling solve has a
real answer for.
"""
if strands is None:
strands = [
{'y': 1.0, 'z_in': 1.5, 'area_in2': 0.153,
'initial_pull_lb': 28900.0, 'label': 'S1'},
{'y': width_ft - 1.0, 'z_in': 1.5, 'area_in2': 0.153,
'initial_pull_lb': 28900.0, 'label': 'S2'},
]
reinforcement = {}
if strands:
reinforcement['prestress'] = {
'diameter_in': 0.5,
'loss_pct': prestress_loss_pct,
'fpu_ksi': 270.0,
'strands': list(strands),
}
else:
reinforcement['prestress'] = None
reinforcement['_note'] = ('no prestress on this piece; capacity is '
'strand-only, so flexural capacity is not '
'computed and says so')
return {
'schema': SCHEMA,
'project_information': {
'mark': mark,
'design_number': design_number,
'fc_psi': fc_psi,
'fci_psi': fci_psi,
'unit_weight_pcf': unit_weight_pcf,
'prestress_loss_pct': prestress_loss_pct,
},
'settings': {
'tension_limit_sqrt_fc': tension_limit_sqrt_fc,
'compression_limit_ratio': compression_limit_ratio,
'phi_flexure': 0.90,
'phi_shear': 0.75,
'development_length_multiplier': 1.0,
'_seeded_from': unread(
'settings are seeded from knowledge/defaults in a later '
'stage; a Stage 0 fixture hand-authors them'),
},
'concrete_extents': {
'length_ft': length_ft,
'width_ft': width_ft,
'thickness_in': thickness_in,
'finish': 'smooth',
'product_type': 'flat',
'weight_lb': weight_lb,
'blockouts': list(blockouts or []),
'recesses': [],
},
'structural_model': {
'supports': [{'x_ft': float(x), 'type': support_type}
for x in support_xs],
'restraints': unread(
'restraints are seeded from clash detection in a later stage '
'(Stage 1); a Stage 0 fixture hand-authors supports only'),
'bearing': unread(
'in-place bearing is not read off any model yet'),
},
'reinforcement': reinforcement,
'loading': {
'self_weight': {'included': True,
'load_factor': self_weight_factor},
'wind': unread('wind / lateral load cases are the in-place engine, '
'built in a later stage (Stage 3)'),
'combinations': unread('ACI 318 load combinations are built with '
'the in-place engine (Stage 3)'),
'service': unread('in-place SERVICE inputs (L/n deflection limit, '
'Class T cracking, initial bow, axial '
'eccentricity) are the in-place engine (Stage '
'3); the ASCE 2.4 defaults run without them'),
},
'handling': {
'_note': 'result block; filled by panelanalysis.py from the '
'self-weight solve on structural_model.supports',
},
'in_place': unread('the in-place wind/gravity engine is not built yet '
'(Stage 3); its result block is a placeholder, '
'never fabricated zeros'),
'source': {
'synthetic': True,
'note': 'Stage 0 hand-authored fixture; not exported from any '
'model',
},
}