52d94303bc
ci / test (push) Has been cancelled
Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
911 lines
40 KiB
Python
911 lines
40 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)
|
|
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
|
|
|
|
|
|
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)
|
|
|
|
|
|
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). The in-place solve refuses fewer
|
|
than two the same way the handling solve does; this reader does not
|
|
second-guess it, it only parses.
|
|
"""
|
|
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))
|
|
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
|
|
|
|
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
|
|
|
|
|
|
def wind_zones(deck, panel=None):
|
|
"""([LateralPressureZone], tributary_width_ft, reason).
|
|
|
|
Three ways a deck states the out-of-plane wind, in priority order:
|
|
|
|
* an explicit `zones` list -- {x0_ft, x1_ft, pressure_psf, label} -- the
|
|
GIVEN-pressure path the goldens and fixtures use, untouched;
|
|
* 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) -- this is the form's path;
|
|
* the `unread(...)` placeholder -> ([], None, reason).
|
|
|
|
The pressure the zone carries is the direct entry if the user gave one, else
|
|
windload's computed C&C value, else nothing (a reason, no fabricated zone).
|
|
"""
|
|
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'
|
|
|
|
trib = blk.get('tributary_width_ft')
|
|
trib = float(trib) if trib is not None else 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 []."""
|
|
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:
|
|
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))))
|
|
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), None
|
|
|
|
|
|
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',
|
|
},
|
|
}
|