# -*- 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..` 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)" -- 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 " -- 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..' 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.