#!/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', }, }