Files
willtools-demo/WillTools.extension/lib/vibe/pa_defaults.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

808 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Aggressive Panel Analysis defaults: fill every blank the tool can defend.
WHY THIS EXISTS. Owner, 2026-09-04: "目标是我按一下 WPA655 的 panel analysis,
然后手动输入 loading, 剩下的它就能跑了" -- press the button, type the loading,
everything else already has a number. The form opened that day with 141 of
315 fields blank. His instruction was explicit that BLANK is the failure mode
here, not a wrong-looking guess -- every field with a defensible default gets
one, and every one of them names where it came from. Only true LOADING (floor
loads, wind speed / pressure, seismic Cs, soil) is left for the user, because
those are the numbers this tool cannot know and must not invent.
WHAT THIS MODULE OWNS. `panelanalysisform.prefill()` already fills what the
model can read plus a small `PREFILL_DEFAULTS` table of generic precast
constants (concrete type, rebar grade, mesh grade, the PCI loss-method
seeds). This module is a SECOND, later pass over the same `values` dict --
covering the much larger set of Design Criteria / Structural Model / Handling
fields that table does not touch, most of which do not even have a home in
`panelanalysisform.FIELDS` yet (they live in `pa_criteria._F` and inline in
`pa_structural.page()` instead, under the `design_criteria.*` / `load_opt_*`
/ `wind_*` / `temp_*` / `restraint_*` / `stage.<slug>.<opt>` naming this
codebase already uses). `apply_defaults()` is meant to run AFTER `prefill()`
in the button script, so the ordinary precedence rule holds without this
module doing anything special for it: a value already sitting in `values` --
a model read, a plant `panelanalysis.settings.*` pin, or a
`PREFILL_DEFAULTS` seed -- always wins, and this module only ever fills what
is STILL blank.
SOURCES CITED IN `why`. Three kinds, and every filled entry names one:
* "Eriksson Wall default (<page> page)" -- the value the owner's own
Eriksson Wall runs show for this field on a fresh panel. This is the
fallback for most of the Design Criteria / Handling settings below --
generic precast practice, not one job's data.
* an ACI 318-19 / ASCE 7-22 section number -- code text, not a guess.
* "plant knowledge <dotted.path>" -- a real value read off the merged
knowledge layers (`know`), which always outranks the generic fallback
for the handful of fields a plant is expected to pin (the handling
stage multipliers and strength, the sling angle, the strand rail
datum, `top_in_form`, `stress_ignore_lateral_ecc`, the engineer's own
initials). Nothing here reads the Revit API or a live document --
`know` is just the already-merged dict the button hands in.
WHAT NEVER GETS A DEFAULT. `ps_loss_pct` (the method computes it),
`ext_soil_loads` / `int_soil_loads` (loading), every raw `seismic_cs` /
`seismic_zone_location` field (loading -- the owner ruled on this
explicitly, 2026-09-02: "风的计算还没有你就留着空着,让用户输入"), and
`engineer_initials` when no plant value names one -- a person's identity is
not a default, the same rule CLAUDE.md states for client and job names.
`conc_loads` (the old free-text field) is gone from the form entirely --
concentrated loads are a row table now (`panelanalysisform.CONC_LOAD_TABLE`),
never defaulted, same rule. ASCE wind (`wind_speed_vult`, `wind_risk_
category`, `wind_exposure`, `wind_enclosure`, `wind_asce_version`,
`wind_cc_chapter`, `wind_mwfrs_chapter`, `wind_mean_roof_height_ft`,
`wind_effective_wind_area_ft2`, `wind_pressure_psf`, `wind_suction_psf`) is
gone from this module too -- 2026-09-04 owner ruling moved ASCE site data
out of this form into project config entirely (see
`panelanalysisform`'s module docstring, Wind section); this module never
defaulted the two real-loading fields among them anyway, and now has
nothing left to default for the rest either.
NEVER RAISES. `defaults_for` / `apply_defaults` / `why_of` / `known_keys` /
`unknown_keys` all degrade to an empty result rather than an exception --
a malformed `values` (`None`, not a dict), a malformed `know` ('garbage',
not a dict), or a sibling module mid-edit (an import failure in
`panelanalysisform` / `pa_criteria` / `pa_structural`, all still being
written the same day this file was) each narrow what gets filled instead of
stopping the whole pass. `known_keys()` follows the same "could not judge
narrows, never guesses" discipline CLAUDE.md's `knowledge.same_face()`
example sets: a module that fails to import contributes no keys, not a
guessed set.
`known_keys()` / `unknown_keys()` READ THE LIVE FORM, not a copy of its key
list. `pa_structural.page()` has no flat field table the way
`panelanalysisform.FIELDS` and `pa_criteria._F` do -- its field keys are
scattered through one long `page()` function as inline `_field(...)` calls.
Rather than hand-copy that list here (and drift the day someone edits it),
`_pa_structural_blocks()` calls `pa_structural.page({}, {})` -- documented as
never raising -- and walks the returned block tree for every `kind: 'field'`
entry, the same shape the WPF renderer itself would see. That also recovers
each field's `choices`, feeding the choice-membership check below.
CHOICE VOCABULARY. Where a key has a `choices` list anywhere in the form
(`panelanalysisform.FIELD_CHOICES`, `pa_criteria._F`'s own choices column,
`pa_structural.page()`'s inline `choices=[...]`, or the Handling stage
matrix's per-column `choices` hint in `STAGE_OPTIONS`), the default picked
below is one of those choices -- verified by
`tests/test_pa_defaults.py`, not just asserted here. Two of the owner's
literal example values did not survive that check against the real editors
and were changed; see the module-level `_DECISIONS` note near the bottom of
this file for both.
IRONPYTHON 2.7: no f-strings, `'{0}'.format(...)` throughout, this coding
cookie, no `pathlib` / `dataclasses` / typing syntax. Imports nothing but the
standard library at module scope; every `vibe.*` import is inside a function,
wrapped in `try/except Exception`, exactly so a sibling module mid-edit can
never take this one down.
"""
import datetime
# --------------------------------------------------------------- key sources
def _panelanalysisform():
try:
from vibe import panelanalysisform as paf
return paf
except Exception:
return None
def _pa_criteria():
try:
from vibe import pa_criteria as pc
return pc
except Exception:
return None
def _pa_structural_blocks():
"""[(key, editor, choices), ...] read off a live `pa_structural.page()`
call -- see the module docstring's "READ THE LIVE FORM" note for why
this walks the block tree instead of keeping a second copy of the field
list. Never raises: an import failure or a `page()` that cannot run
(both plausible while a sibling agent edits either file) returns [].
"""
try:
from vibe import pa_structural as ps
except Exception:
return []
try:
blocks, _problems = ps.page({}, {})
except Exception:
return []
out = []
def walk(items):
for b in items or []:
if not isinstance(b, dict):
continue
kind = b.get('kind')
if kind == 'field' and b.get('key'):
out.append((b.get('key'), b.get('editor'), b.get('choices')))
elif kind == 'columns':
for col in (b.get('columns') or []):
walk(col)
elif kind == 'section':
# A collapsed section (seismic / temperature / soil since
# 2026-09-04) carries its fields in `blocks`; they are still
# the form's keys.
walk(b.get('blocks'))
walk(blocks)
return out
def known_keys():
"""Every key the Panel Analysis form is known to carry today, gathered
defensively from every section module this file knows how to ask:
`panelanalysisform.FIELDS`, the Handling stage matrix
(`stage_field_keys()`), `pa_criteria._F`, and a live read of
`pa_structural.page()`'s field blocks. A module that fails to import
only narrows this set -- see the module docstring.
"""
keys = set()
paf = _panelanalysisform()
if paf is not None:
try:
keys.update(f['key'] for f in paf.FIELDS)
except Exception:
pass
try:
keys.update(paf.stage_field_keys())
except Exception:
pass
pc = _pa_criteria()
if pc is not None:
try:
keys.update(pc._F.keys())
except Exception:
pass
for key, _editor, _choices in _pa_structural_blocks():
keys.add(key)
return keys
def unknown_keys():
"""The keys this module WOULD default that `known_keys()` does not (yet)
recognise -- empty once the field-model agents have caught up. Never
raises; a lookup failure inside `known_keys()` already degrades to a
smaller set, so this can only ever over-report, never under-report, a
drift.
"""
known = known_keys()
out = []
for key in _all_default_keys():
if key not in known and key not in out:
out.append(key)
return out
# ------------------------------------------------------------- choice tables
def _choice_registry():
"""key -> tuple(choices), unioned across every source that names one.
A key with no entry here is free text (or a key nothing declares a
choice list for yet) -- `choices_for` returns None for it, and the
default picked for it is not checked against anything.
"""
out = {}
def add(key, choices):
if not choices:
return
cur = out.get(key, ())
merged = tuple(cur) + tuple(c for c in choices if c not in cur)
out[key] = merged
paf = _panelanalysisform()
if paf is not None:
try:
for key, choices in paf.FIELD_CHOICES.items():
add(key, choices)
except Exception:
pass
try:
for stage in paf.STAGES:
for opt_key, _label, _sym, _help, _kind, _drives, choices \
in paf.STAGE_OPTIONS:
add(paf.stage_field_key(stage, opt_key), choices)
except Exception:
pass
pc = _pa_criteria()
if pc is not None:
try:
for key, spec in pc._F.items():
choices = spec[5] if len(spec) > 5 else None
add(key, choices)
except Exception:
pass
for key, _editor, choices in _pa_structural_blocks():
add(key, choices)
return out
def choices_for(key):
"""The unioned choice vocabulary for `key`, or None for free text."""
return _choice_registry().get(key) or None
# ------------------------------------------------------------------ helpers
def _is_blank(value):
if value is None:
return True
try:
return value.strip() == ''
except AttributeError:
return False
def _num(text):
"""A best-effort float from whatever sits in `values`, or None."""
if text is None:
return None
try:
return float(text)
except (TypeError, ValueError):
return None
def _fmt_num(value):
"""A number as the shortest text the form shows -- '2' not '2.0'."""
if value is None:
return ''
if isinstance(value, bool):
return 'Yes' if value else 'No'
try:
if isinstance(value, int):
return str(value)
except Exception:
pass
try:
f = float(value)
except (TypeError, ValueError):
return str(value)
if f == int(f):
return str(int(f))
text = ('%.10f' % f).rstrip('0').rstrip('.')
return text
def _today_iso():
return datetime.date.today().isoformat()
def _as_dict(value):
return value if isinstance(value, dict) else {}
# ---------------------------------------------------------- static defaults
#
# key -> (value_text, why). Fixed values -- no plant lookup, no computation
# from another field -- for every Design Criteria / Structural Model /
# Handling field the owner named that does not depend on plant knowledge or
# on another field's value. See `_DYNAMIC_DEFAULTS` and `_stage_dynamic_*`
# below for the ones that do.
_ERIKSSON = 'Eriksson Wall default'
def _e(page):
return '{0} ({1} page)'.format(_ERIKSSON, page)
_STATIC_DEFAULTS = (
('design_spec', 'ACI 318-19',
'current governing code edition (ASCE 7-22 companion); the sample '
'Eriksson runs printed ACI 318-14 because they were older files'),
# ---------------------------------------------------- Design Criteria
('design_criteria.deflection_add_bow', 'No', _e('Settings')),
('design_criteria.deflection_additional_bow_in', '0', _e('Settings')),
('design_criteria.deflection_check_cantilever', 'No', _e('Settings')),
('design_criteria.deflection_cantilever_limit', 'L/75', _e('Settings')),
('design_criteria.analysis_beta_d', 'Non-Sway',
"matches the field's own choice vocabulary (Non-Sway / Sway) -- "
"Non-Sway is " + _e('Settings')),
('design_criteria.cracking_method', 'Mast Method', _e('Settings')),
('design_criteria.cracking_limit_sqrt_fc', '7.5', _e('Settings')),
('design_criteria.rvalue_calc_enabled', 'No', _e('Settings')),
('design_criteria.min_reinf_checked', 'No', _e('Settings')),
('design_criteria.stress_failure_if_cracked', 'No', _e('Settings')),
('design_criteria.stress_use_principal_axis', 'No', _e('Settings')),
('design_criteria.inplace_modulus_rupture_sqrt_fc', '12',
u"Eriksson's own \"transition\" tension limit, 12 x sqrt(f'c)"),
('design_criteria.flexure_method', 'Whitney Stress Block',
_e('Settings')),
('design_criteria.flexure_ignored_end_distance_in', '0',
_e('Settings')),
('design_criteria.flexure_cracking_check', 'None', _e('Settings')),
('design_criteria.phi_compression', '0.65', "ACI 318-19 Sec. 21.2"),
('design_criteria.phi_tension_transfer', '0.75', "ACI 318-19 Sec. 21.2"),
('design_criteria.phi_shear_transfer', '0.75', "ACI 318-19 Sec. 21.2"),
('design_criteria.dev_mult_debonded', '2', _e('Settings')),
('design_criteria.dev_mult_cut', '1.3', _e('Settings')),
('design_criteria.dev_mult_mild', '1.3', _e('Settings')),
('dev_mult', '1.3', _e('Settings') + ' -- strand, normal'),
('design_criteria.shear_treat_as_column', 'No', _e('Settings')),
('design_criteria.axial_ignore_tensile_failures', 'No', _e('Settings')),
('design_criteria.axial_ignore_buckling_failures', 'No', _e('Settings')),
('design_criteria.min_earth_factor_strength', '0', _e('Settings')),
('design_criteria.min_earth_factor_service', '0', _e('Settings')),
('second_order', 'Yes', _e('Settings') + ' -- P-Delta'),
('deflection_limit', 'L/150', _e('Settings')),
# ------------------------------------------------------- Load Options
('self_weight_factor', '1', _e('Settings')),
('load_opt_selfweight_ecc_in', '0', _e('Settings')),
('load_opt_offset_from', 'Deflection Section',
'Eriksson calls this the Loading Datum; the sample runs measure '
'point-load offsets from the Deflection Section'),
('load_opt_pattern_live', 'No',
_e('Settings') + ' -- Pattern Live Loads for Flexure is off in the '
'sample runs'),
# -------------------------------------------------------- Temperature
('temp_ext_f', '75', _e('Settings')),
('temp_int_f', '75', _e('Settings')),
('temp_elasticity_mult', '0.5', _e('Settings')),
('temp_alpha_t', '5.5',
u'5.5 × 10⁻⁶ /°F (ACI 209 / PCI), entered as the '
u'×10⁻⁶ mantissa the field asks for'),
# Wind is GONE from this module -- 2026-09-04 owner ruling moved every
# ASCE site-data field (Vult, risk/exposure/enclosure, code version,
# C&C/MWFRS chapter, mean roof height, effective wind area) out of this
# form into project config; see `panelanalysisform`'s module docstring.
# `wind_mode` (the one wind field left) defaults on the form side, in
# `panelanalysisform.prefill` itself, not here.
# ---------------------------------------------------------- Restraints
# ------------------------------------------------------------ Seismic
# 'seismic_apply_to' picks WHAT the (still-blank, user-typed) seismic
# coefficient multiplies -- not a magnitude itself, so it is not the
# loading the owner ruled must stay blank. Its editor carries an
# explicit 'None' choice now, matched here rather than left blank.
('seismic_apply_to', 'None',
"matches the field's own choice vocabulary (None / Out-of-plane / "
'In-plane / Both); ' + _e('Structural Model') + ' -- no seismic '
'multiplier applied in the sample runs'),
# ------------------------------------------------- Prestressing Strands
('strand_rail_offset_in', '0', _e('Reinforcement')),
# -------------------------------------------------------------- Extents
('left_miter', 'No', _e('Concrete Extents')),
('right_miter', 'No', _e('Concrete Extents')),
)
def _all_static_keys():
return [key for key, _v, _w in _STATIC_DEFAULTS]
# ---------------------------------------------------------- dynamic values
#
# key -> function(values, know) -> (value_text, why) | None. None means
# "this module cannot defend a default here right now" -- the key stays
# blank, never a fabricated fallback. See the module docstring for the
# plant-knowledge paths each of these reads.
def _dyn_project_date(values, know):
return _today_iso(), "today's date, the same convention design_date uses"
def _dyn_engineer_initials(values, know):
pa = _as_dict(_as_dict(know).get('panelanalysis'))
value = pa.get('engineer_initials')
if value is None:
return None
text = str(value).strip()
if not text:
return None
return text, 'plant knowledge panelanalysis.engineer_initials'
def _dyn_stress_ignore_lateral_ecc(values, know):
pa = _as_dict(_as_dict(know).get('panelanalysis'))
settings = _as_dict(pa.get('settings'))
value = settings.get('stress_ignore_lateral_ecc')
if value is not None:
text = str(value).strip().lower()
yn = 'No' if text in ('', 'no', 'none', 'false', '0', 'off') \
else 'Yes'
return yn, ('plant knowledge '
'panelanalysis.settings.stress_ignore_lateral_ecc')
return 'No', _e('Settings')
def _dyn_top_in_form(values, know):
pa = _as_dict(_as_dict(know).get('panelanalysis'))
value = pa.get('top_in_form')
if value is not None:
text = str(value).strip()
if text:
for choice in ('Interior', 'Exterior'):
if text.lower() == choice.lower():
text = choice
break
return text, 'plant knowledge panelanalysis.top_in_form'
return 'Interior', _e('Concrete Extents')
_RAIL_LABELS = {
'left_rail': 'Left edge',
'right_rail': 'Right edge',
'centerline': 'Centerline',
}
def _dyn_strand_rail_location(values, know):
rails = _as_dict(_as_dict(know).get('rails'))
datum = rails.get('strand_datum')
if datum is not None:
label = _RAIL_LABELS.get(str(datum).strip().lower())
if label:
return label, 'plant knowledge rails.strand_datum'
return 'Left edge', _e('Reinforcement')
_DYNAMIC_DEFAULTS = (
('project_date', _dyn_project_date),
('engineer_initials', _dyn_engineer_initials),
('design_criteria.stress_ignore_lateral_ecc',
_dyn_stress_ignore_lateral_ecc),
('top_in_form', _dyn_top_in_form),
('strand_rail_location', _dyn_strand_rail_location),
# `wind_effective_wind_area_ft2` / `wind_mean_roof_height_ft` are GONE --
# see the module docstring's Wind note; both fields left the form.
)
# --------------------------------------------------------- handling stages
#
# `panelanalysisform.stage_field_key(stage, opt)` builds
# 'stage.<slug>.<opt>' from the stage NAME, not the slug -- but this module
# hardcodes the slugs too (matching `_stage_slug`'s
# `name.lower().replace(' ', '_')` exactly) rather than importing the form
# at module scope, so a sibling agent mid-edit on `panelanalysisform.py`
# cannot take the whole default table down with it. `known_keys()` still
# checks these against the live `stage_field_keys()` output, so any drift
# between the hardcoded slug and the real one shows up as an `unknown_keys()`
# entry rather than silently mis-keying the form.
_STAGES = ('Stripping', 'Transportation', 'Primary Erection',
'Secondary Erection')
_STAGE_SLUGS = {
'Stripping': 'stripping',
'Transportation': 'transportation',
'Primary Erection': 'primary_erection',
'Secondary Erection': 'secondary_erection',
}
_STAGE_PLANT_NAME = {
'Stripping': 'stripping',
'Transportation': 'transportation',
'Primary Erection': 'erection',
'Secondary Erection': None,
}
_STAGE_FALLBACK_MULTIPLIER = {
'Stripping': 1.4,
'Transportation': 1.5,
'Primary Erection': 1.4,
}
_STAGE_FALLBACK_STRENGTH_LABEL = {
'Stripping': 'Initial',
'Transportation': 'In-Place',
'Primary Erection': 'In-Place',
}
_STAGE_FALLBACK_MAX_TILT = {
'Stripping': 0,
'Transportation': 0,
'Primary Erection': 90,
}
_STRENGTH_LABELS = {'fci': 'Initial', 'fc': 'In-Place'}
def _stage_key(stage, opt):
return 'stage.{0}.{1}'.format(_STAGE_SLUGS[stage], opt)
def _plant_stage(know, plant_name):
if plant_name is None:
return None
handling = _as_dict(_as_dict(know).get('handling'))
stages = handling.get('stages')
if not isinstance(stages, list):
return None
for entry in stages:
if isinstance(entry, dict) and entry.get('name') == plant_name:
return entry
return None
def _plant_sling_angle(know):
handling = _as_dict(_as_dict(know).get('handling'))
value = handling.get('sling_angle_deg')
return value if value is not None else None
# Static (non-plant-dependent) cells, one set per active stage -- generated
# once into `_STATIC_DEFAULTS`-shaped tuples so they go through the exact
# same blank-check / why_of machinery as everything else. Only
# `load_multiplier`, `concrete_strength` and `sling_angle` are plant-aware
# (see `_stage_dynamic_defaults` below); everything else about a stage is
# the same Eriksson-sample answer regardless of plant.
def _stage_static_defaults():
out = []
for stage in _STAGES:
if stage == 'Secondary Erection':
out.append((
_stage_key(stage, 'include'), 'No',
_e('Handling') + ' -- secondary erection is not run for '
'this panel unless entered'))
continue
out.append((_stage_key(stage, 'include'), 'Yes', _e('Handling')))
out.append((_stage_key(stage, 'lift_type'), 'Static Lift',
_e('Handling')))
stress_limits = 'Handling' if stage == 'Stripping' else 'In-Place'
out.append((_stage_key(stage, 'stress_limits'), stress_limits,
_e('Handling')))
out.append((_stage_key(stage, 'rigging'), 'Auto', _e('Handling')))
out.append((_stage_key(stage, 'ground_reaction'), 'No',
_e('Handling')))
out.append((_stage_key(stage, 'min_tilt_deg'), '0', _e('Handling')))
out.append((_stage_key(stage, 'max_tilt_deg'),
_fmt_num(_STAGE_FALLBACK_MAX_TILT[stage]),
_e('Handling')))
out.append((_stage_key(stage, 'top_crane_carries'), 'No',
_e('Handling')))
return tuple(out)
_STAGE_STATIC_DEFAULTS = _stage_static_defaults()
def _stage_dynamic_defaults(values, know):
out = []
sling = _plant_sling_angle(know)
if sling is not None:
sling_text = _fmt_num(sling)
sling_why = 'plant knowledge handling.sling_angle_deg'
else:
sling_text = '60'
sling_why = _e('Handling')
for stage in _STAGES:
if stage == 'Secondary Erection':
continue
plant_name = _STAGE_PLANT_NAME.get(stage)
pstage = _plant_stage(know, plant_name)
multiplier = None
mult_why = _e('Handling')
if pstage is not None and pstage.get('multiplier') is not None:
multiplier = pstage.get('multiplier')
mult_why = 'plant knowledge handling.stages'
if multiplier is None:
multiplier = _STAGE_FALLBACK_MULTIPLIER[stage]
out.append((_stage_key(stage, 'load_multiplier'),
_fmt_num(multiplier), mult_why))
strength_label = _STAGE_FALLBACK_STRENGTH_LABEL[stage]
strength_why = _e('Handling')
if pstage is not None and pstage.get('strength') in \
_STRENGTH_LABELS:
strength_label = _STRENGTH_LABELS[pstage.get('strength')]
strength_why = 'plant knowledge handling.stages'
out.append((_stage_key(stage, 'concrete_strength'), strength_label,
strength_why))
out.append((_stage_key(stage, 'sling_angle'), sling_text,
sling_why))
return out
def _stage_dynamic_keys():
keys = []
for stage in _STAGES:
if stage == 'Secondary Erection':
continue
keys.append(_stage_key(stage, 'load_multiplier'))
keys.append(_stage_key(stage, 'concrete_strength'))
keys.append(_stage_key(stage, 'sling_angle'))
return keys
# --------------------------------------------------------------- the WHY map
_WHY = {}
for _key, _value, _why in _STATIC_DEFAULTS:
_WHY[_key] = _why
for _key, _value, _why in _STAGE_STATIC_DEFAULTS:
_WHY[_key] = _why
del _key, _value, _why
_WHY['project_date'] = ("today's date, the same convention design_date "
'uses')
_WHY['engineer_initials'] = (
'plant knowledge panelanalysis.engineer_initials, when the plant names '
'one -- a person\'s identity is never guessed, so this key is left '
'blank without a plant value')
_WHY['design_criteria.stress_ignore_lateral_ecc'] = (
'plant knowledge panelanalysis.settings.stress_ignore_lateral_ecc when '
'set, else ' + _e('Settings'))
_WHY['top_in_form'] = ('plant knowledge panelanalysis.top_in_form when '
'set, else ' + _e('Concrete Extents'))
_WHY['strand_rail_location'] = ('plant knowledge rails.strand_datum when '
'set, else ' + _e('Reinforcement'))
for _stage in _STAGES:
if _stage == 'Secondary Erection':
continue
_WHY[_stage_key(_stage, 'load_multiplier')] = (
'plant knowledge handling.stages when a matching stage names a '
'multiplier, else ' + _e('Handling'))
_WHY[_stage_key(_stage, 'concrete_strength')] = (
"plant knowledge handling.stages' strength field (fci -> Initial, "
'fc -> In-Place) when present, else ' + _e('Handling'))
_WHY[_stage_key(_stage, 'sling_angle')] = (
'plant knowledge handling.sling_angle_deg when set, else ' +
_e('Handling'))
del _stage
def why_of(key):
"""The source sentence for `key`'s default, or None if this module does
not know how to default that key. Independent of any particular
`values` / `know` -- see the module docstring for why: for a
plant-aware key this names the RULE ("plant knowledge X when set, else
Y"), not necessarily which branch a specific call took.
"""
try:
return _WHY.get(key)
except Exception:
return None
def _all_default_keys():
keys = list(_all_static_keys())
keys.extend(key for key, _v, _w in _STAGE_STATIC_DEFAULTS)
keys.extend(key for key, _fn in _DYNAMIC_DEFAULTS)
keys.extend(_stage_dynamic_keys())
return keys
# ------------------------------------------------------------------- the API
def _compute(values, know):
out = []
seen = set()
def emit(key, value_text, why):
if key in seen:
return
seen.add(key)
if not _is_blank(values.get(key)):
return
out.append((key, value_text, why))
for key, value_text, why in _STATIC_DEFAULTS:
emit(key, value_text, why)
for key, value_text, why in _STAGE_STATIC_DEFAULTS:
emit(key, value_text, why)
for key, fn in _DYNAMIC_DEFAULTS:
try:
result = fn(values, know)
except Exception:
result = None
if result is None:
continue
value_text, why = result
emit(key, value_text, why)
try:
stage_dyn = _stage_dynamic_defaults(values, know)
except Exception:
stage_dyn = []
for key, value_text, why in stage_dyn:
emit(key, value_text, why)
return out
def defaults_for(values, know=None):
"""[(key, value_text, why), ...] for every key whose current value in
`values` is blank ('' / None / missing) and that this module knows a
default for. `values` and `know` are read only, never mutated. Never
raises -- a `values` that is not a dict, or a `know` that is not a
dict, both degrade to []/an empty knowledge tree rather than an error.
"""
if not isinstance(values, dict):
return []
try:
return _compute(values, know if isinstance(know, dict) else {})
except Exception:
return []
def apply_defaults(values, know=None):
"""Fills the blanks `defaults_for` finds, IN PLACE on `values`, and
returns the same [(key, value_text, why), ...] it applied. A value
that is no longer blank by the time this writes it (nothing here
should cause that, but the check costs nothing) is left untouched --
this never overwrites a real value. Never raises: `values` that is not
a (mutable) dict returns [] and nothing is written.
"""
if not isinstance(values, dict):
return []
try:
found = defaults_for(values, know)
except Exception:
return []
applied = []
for key, value_text, why in found:
try:
if _is_blank(values.get(key)):
values[key] = value_text
applied.append((key, value_text, why))
except Exception:
continue
return applied
# --------------------------------------------------------------- _DECISIONS
#
# `design_criteria.analysis_beta_d` and `load_opt_offset_from` were checked
# against `pa_criteria`'s and `pa_structural`'s real editors while this file
# was being written and, at that point, neither editor's choice list
# actually contained the owner's own example value (a numeric-only editor
# for the first, `['Panel CG', 'Other']` for the second). Both editors were
# given real choice lists by another agent, concurrently, before this file's
# tests were finalised -- `design_criteria.analysis_beta_d` now offers
# `('Non-Sway', 'Sway')` and `load_opt_offset_from` now offers
# `('Deflection Section', 'Top in Form', 'Bottom in Form')` -- and both
# match the owner's original values exactly, so both are encoded as he
# specified. Left as a note because `choices_for()` reads the LIVE editors
# on every call: if either drifts again, `tests/test_pa_defaults.py::
# test_every_default_with_choices_is_a_valid_choice` fails and names the
# mismatch rather than this comment going stale silently.
#
# dev_mult / second_order Both already carry a DIFFERENT generic default
# in `panelanalysisform.PREFILL_DEFAULTS`
# ('1' and 'No', committed 2026-09-03) that runs
# unconditionally in `prefill()`, before this
# module's `apply_defaults()` would ever see the
# key -- so under the current call order neither
# of this module's defaults (1.3, 'Yes') can ever
# actually fire; `values[key]` is already
# non-blank by the time `apply_defaults` looks at
# it. Encoded as the owner specified anyway
# (2026-09-04 postdates the 2026-09-03 seed), but
# this is a live policy conflict between the two
# tables, not a bug in either -- flagged for
# whoever wires `apply_defaults()` into `prefill()`
# to resolve by call order or by retiring the
# older seed.