# Robustness audit: loadtracer 0.5.1 **Status:** Fixes implemented in 0.6.0 **Branch:** `refactor/consistency` **Author:** Connor Ferster **Date:** 2026-09-18 --- ## 1. Question A load rundown often breaks when the drawings vary even slightly. This audit answers: - Which inputs crash loadtracer, and why? - Which assumptions about the data are built into the code? - What has to change to make it robust? It also looks for the opposite problem: inputs that **don't** crash but give a wrong answer. Those turned out to be the more serious findings. ## 2. Method 1. **Fixture sweep.** Every PDF in `../papermodels/tests/test_data` was built into a `GeometryGraph` with papermodels 0.21 (`JoistArrayModel`, spacing 1.0), then run through `LoadTracer`. The occupancies were synthetic: `{"D": 1.0, "L": 2.5}` for each occupancy found in the drawing. 2. **Shape survey.** The `LoadedElement.model()` output of every fixture was checked for: - list-valued point load locations - `[]` extents - loads outside the member - zero-length loads - beams with fewer than two supports 3. **Probes.** Each suspected failure was reproduced against the 0.5.1 code with hand-built papermodel dicts (`tests/builders.py`). 4. **Load conservation.** For each load case, the total occupancy load applied to the graph has to arrive at the members at the bottom of the graph (see `tests/conservation.py`). "Doesn't crash" is too weak a test for a load takedown. This invariant is what found F8 and F13. Every finding is pinned by a test in `tests/test_sensitivity.py` (unit level) or `tests/test_integration_papermodels.py` (fixtures). ## 3. Results at a glance | Fixture | 0.5.1 | 0.6.0 | Load conserved (0.6.0) | |---|---|---|---| | collector_extents.pdf | `ReactionNotFoundError` (F4) | runs | yes (no loads drawn) | | collector_extents_walls.pdf | runs | runs | yes | | frame_collectors_transfers.pdf | `ReactionNotFoundError` (F4) | runs | yes | | geometry_testing.pdf | `ReactionNotFoundError` (F4) | runs | yes (no loads drawn) | | horiz_extents.pdf | runs | runs | yes | | intersections.pdf | runs | runs | yes | | many_correspondents.pdf | `ReactionNotFoundError` (F4) | runs | **+7.3%**: walls on overlapping supports (F13, reported) | | resi_dormers.pdf | runs | runs | yes | | sketch_to_scale.pdf | `ReactionNotFoundError` (F4) | runs | yes | | three-pages-on-one.pdf | papermodels `KeyError: None` | same | n/a (upstream, U3) | | trib-areas-basic.pdf | runs | runs | yes | 5 of the 10 fixtures that papermodels can build crashed in loadtracer 0.5.1. In 0.6.0 all 10 run. Load is conserved exactly (to 1e-6) on 9 of them. On the 10th, the extra load comes only from walls whose supports overlap, and each one is reported in `LoadTracer.issues`. ## 4. Findings "Silent" means that 0.5.1 returned a result without raising an error, and that result was wrong. | # | Input | 0.5.1 symptom | Root cause (0.5.1) | Fix (0.6.0) | |---|---|---|---|---| | F1 | Beam with 0 supports, or 1 support | 0 supports: `UnboundLocalError: node_name`. 1 support mid-span: **silent**, a single reaction of −5.5 for a load of 10 (the member was cut off at the support). 1 support at the end: **silent**, −9 | `frame_model.py:158-170`: the end nodes are only added when `idx == 0` or `idx == len - 1`, so a single support takes only the first branch. Nothing checks stability. | `get_node_locations` adds free end nodes independently of the supports. It raises `UnstableMemberError` unless there are ≥ 2 supports including a pin, or a `fixed` support. | | F2 | Loads outside [0, L], or with zero length. Common: joist trib areas project 0.06–0.32 past the joist ends, and one fixture (`resi_dormers.pdf`) has a load lying entirely past its member's end | Distributed load 0.5 past each end: **silent**, 1.0 of 11.0 lost. Point load 0.2 past the end: **silent**, reactions `{}`, so the load disappears | Pynite ignores the part of a load outside the member. `wall_model.py:96,121` clamps only `end_loc`, and mutates the point load it is given. | `validation.conform_loads_to_member` clips every expanded load to the member. The part outside becomes a point load at the nearest end, so the total load is exact. Each correction is recorded in `LoadTracer.issues`. | | F3 | Any exception while analysing a member | `ReactionNotFoundError: Error calculating reaction for: X` for *every* error. The cause is lost, and `KeyboardInterrupt` is caught too | Bare `except:` in `load_tracer.py:212` and `expand_reactions.py:181`. The error message at `expand_reactions.py:183` indexes `member_reactions[tag]`, which raises a `KeyError` of its own. | Error hierarchy in `errors.py` (`LoadTracerError` → `ReactionNotFoundError`, `UnstableMemberError`, `InvalidPapermodelError`, `AnalysisError`). Every error carries the member tag, and unexpected errors are wrapped with `raise … from e`. New `on_error="collect"` records failures in `.errors` and continues. | | F4 | A member with no load. The load area doesn't reach it, or it's a drawn member that isn't loaded yet | `ReactionNotFoundError` on every member below it. **This is the main reason 0.5.1 "breaks when there is any variance in the input"**: it caused all 5 fixture crashes | The member has no load cases, so Pynite has no combinations, so `extract_node_reactions` returns `{}`. Then `expand_reactions.py:107` treats `{}` / `{0: {}}` as "missing". | Reactions are keyed by support index and always list every support (`{0: {}, 1: {}}` for an unloaded member). A support that was analysed and has no load contributes no load components. "Missing" now means only that the source wasn't analysed or has no such index. | | F5 | A support with zero reaction (for example, a point load directly over the middle of three supports) | **Silent**: the reaction indexes shift. The middle support's reaction was reported as index 0 and applied to the member under the *first* support | `pynite_tools.extract_node_reactions` drops any node whose reactions are all ≈ 0, and `expand_reactions.py:243` maps node → index by `enumerate`. | `frame_model.get_support_reactions` reads the reactions of the support nodes directly, in support order, zeros included. | | F6 | Reaction missing for `transfer_reaction_index` 0, or index out of range | `AssertionError` (not caught by `missing_reactions="ignore"`). An out-of-range index: **silent**, zero load | `if transfer_index and (...)` at `expand_reactions.py:152` is falsy for index 0, and the code then reaches `assert` at `:333`. Out of range falls back to `[null_point_load]` at `:135`. | `get_indexed_reaction` looks up the index explicitly. Anything unavailable raises `ReactionNotFoundError`, which names the source and the index, or is recorded under `"ignore"`. | | F7 | Any transfer load | **Silent**: each transfer load appears twice in `expanded_pm`, once with its expansion and once without. Loads without an occupancy were also duplicated once per analysis direction | `expand_reactions.py:113,138` and `expand_occupancies.py:103` append to lists that the `deepcopy` already filled. The shallow `copy()` shared `expanded` lists with the input. | The load lists are rebuilt from the deep copy, and each load appears exactly once. | | F8 | A wall with both point loads and distributed loads | **Silent**: the distributed part of the wall's reaction never reaches the member below | `wall_model.py:116` hard-codes `dir="Fy"` for distributed loads, while point loads use `"FY"`, so the reactions are split over two keys. `expand_reactions.py:344` falls back to `"Fy"` only when `"FY"` is absent. | Each load keeps its own `dir`. The `.title()` workaround is removed. | | F9 | Walls stacked on walls | **Silent**: the load changes sign at every wall. A +5 load into `WT1.0` arrived at `WT0.0` as −5 (in total) | Walls report reactions with the sign of the load, but frames and columns report the force on the support. loadbearing-wall 0.3.0 (the version in the 0.5.1 lock) accepts `reverse_reaction_force_direction` but never uses it. | loadbearing-wall 0.5.0 applies the flag. loadtracer now requires `loadbearing-wall>=0.5.0` and passes `reverse_reaction_force_direction=True` explicitly, so every model reports support forces. | | F10 | A wall over more than one member, or over a shorter member | **Silent**: the *whole* wall reaction was applied to each member under it (+22.7% on `many_correspondents`) | `expand_distributed_reactions` ignored `transfer_source_start_extent` / `transfer_source_end_extent`. | `map_wall_reaction` takes only the part of the wall over each receiver, and scales it so the force is conserved when drawn lengths differ. `assign_wall_transfer_cells` shares out the parts of a wall that aren't over any member (overhangs, gaps, and reactions that the minimum point spread pushes past the wall ends), so the whole reaction is transferred exactly once. | | F11 | No `elevations` / `vertical_element_heights`; a lowercase or non-standard tag; a floor index beyond the elevations | `ValueError`, `AttributeError: 'NoneType' has no attribute 'groups'`, `IndexError` | `pm_processing.py:17,43,21`. Heights are design data, but loadtracer required them for every vertical element. | **Removed.** Heights and elevations are no longer inputs; passing them gives a `DeprecationWarning`. `LinearWallModel` uses height only for a non-zero spread angle (verified in its source and pinned by a test). That case reads `height` from the wall's annotation, or raises. | | F12 | Structural problems in the papermodel: unknown orientation, missing length, list-valued locations, `[]` extents, unknown direction or occupancy, empty occupancies | `UnboundLocalError: member_model`, `KeyError`, `TypeError: [] + float`, `IndexError` at `load_tracer.py:67` | No validation of the input. Model selection at `load_tracer.py:170` had no else branch. | `validation.validate_papermodel` checks the structure with pattern matching, and raises `InvalidPapermodelError` with the tag. List locations on columns (from papermodels, see U1) become 0.0, and `[]` extents become the full length. Occupancies are validated at construction. | | F13 | A wall linked to members that overlap under it | Double counting (the remaining +7.3% on `many_correspondents`). For example, `WB1.0` bears on `WT1.3` along its whole length *and* on `FB1.2`/`FB1.3` under parts of it | How the load divides between two supports under the same stretch of wall depends on their stiffness, which the graph doesn't carry. | **Detected and reported, not resolved.** The overlap is applied to both members and recorded in `LoadTracer.issues[wall]`, with a warning. See U2. | | F14 | Excluding a joist array by its parent tag (`exclude_members=["J1.0"]`) | **Silent**: the joists (`J1.0-0`…) were still analysed and their loads still applied | `expand_reactions.py:105,147` matched exact tags only. | `is_excluded` also matches the parent of `PARENT-idx` tags. | | F15 | A column under a wall (a distributed load on a point column) | **Silent**: the load was dropped | `column_model.build_column_model` read only point loads. | Distributed loads are added to the column as their resultants. | ## 5. Assumptions that remain (documented, not changed) - **Reaction order.** `transfer_reaction_index` refers to the source member's supports sorted by location, which is how papermodels orders them. Supports that coincide are kept, so the indexes still line up, but only the first is modelled. - **Support fixity.** The first support is a pin and the others are rollers, unless a support dict has a `fixity` (`"pin"`, `"roller"`, `"fixed"`). - **Stiffness and continuity.** Beams use a unit, uniform EI, and a beam over more than two supports is continuous. Loads are distributed as a prismatic continuous member would distribute them, which can differ from simple-span hand calculations. A heavily loaded long span can lift the end of a short adjacent span: `FB3.1` in `sketch_to_scale.pdf` (spans 13.4 + 4.9) puts 59.6 of uplift into `CT3.2` in the L case. - **Walls.** A wall's reaction maps onto the member below in the same direction, from source extents to target extents. Both are measured from each member's positive-x end, which is the papermodels convention. Walls use `minimum_point_spread` to spread point loads. - **Columns** are concentric: point load locations are ignored. - **Gravity only.** The `inplane` and `out_of_plane` mappings are wired through but no fixture or test uses them. - **Pressures.** Occupancy loads are the pressure × the magnitudes from papermodels (trib widths). The `applied_area_ratio` scaling for linear-reaction collectors was not audited. ## 6. Upstream recommendations | # | Project | Recommendation | |---|---|---| | U1 | papermodels | `LoadedElement._get_transfer_loads` gives point correspondents on a polygon the **whole list** of transfer locations as `location` (`[]`, `[0.0]`, `[0.0, 0.0, 0.0]` on the fixtures). It should give a single value. | | U2 | papermodels | A wall can be linked to members that overlap under it (F13). The graph should link each part of a wall to one support, or carry enough information to divide the load between them. | | U3 | papermodels | `three-pages-on-one.pdf` fails in `GeometryGraph.from_pdf_file` with `KeyError: None`. | | U4 | papermodels | Occupancy loads on joists commonly extend 0.06–0.32 past the joist ends (the trib band is wider than the joist line). loadtracer moves this load to the ends; confirm whether it's intended. | | U5 | papermodels | Point transfer loads from joists carry no trib extents. `JoistArrayModel` already calculates them for each crossing (`_trib_extent_on_support`). Exposing them would make the joist point-to-distributed load conversion (Task 2) exact at array ends. | | U6 | loadbearing_wall | Fixed in 0.5.0: `reverse_reaction_force_direction` was ignored in 0.3.0 (F9). Still open: `print()` calls in `spread_loads` / `apply_spread_angle` write to stdout on every analysis, and reactions from `minimum_point_spread` can extend past the wall ends. | | U7 | pynite_tools | `extract_node_reactions` drops nodes whose reactions are all zero, which breaks index-based consumers (F5), and it rounds values to nearby integers. | | U8 | PyniteFEA | Fixed in 3.x: 1.6.2 (the version in the 0.5.1 lock) imported `pip._vendor.pkg_resources` in `__init__` and failed in environments without pip. The lock is now on 3.2.0. | | U9 | papermodels | `paper/pdf.py` calls `pikepdf.Stream.read_bytes`, which later pikepdf 10.x releases no longer have (it fails on 10.13, works on 10.0.2). papermodels doesn't bound its pikepdf version, so a fresh install can't read PDFs. loadtracer's lock holds pikepdf at 10.0.2. | | U10 | papermodels | Fixed on papermodels `main` (2a3760c, PR #145), not yet released: papermodels' `Examples/Example 5/multi-page-plan.pdf` failed in `GeometryGraph.from_elements` → `collector_elements` (`geometry_graph.py:106`) with `KeyError: 'element'`: a graph node had no element (an edge referred to a tag that wasn't a node yet). Releases up to and including 0.22.2 still fail. With `main`, the graph builds and loadtracer's Example 5 runs to completion. | | U11 | papermodels | papermodels' `Examples/Example 2`: the PDF tags a beam on page 2 `FB2.5`, but the graph names it `FB2.0`. The manual tags `FB1.14` and `CB0.10` are kept. | ## 7. Behaviour changes in 0.6.0 - Dependencies: `loadbearing-wall>=0.5.0`. The lock is on PyniteFEA 3.2.0 and pynite-tools 0.7.0 (it was 1.6.2 / 0.6.7). The 0.5.1 symptoms in §4 were observed on the old lock; the 0.6.0 results in §3 are on the new one. - `LoadTracer(elevations=…, vertical_element_heights=…)` is deprecated: the values are ignored, with a warning. `pm_processing.py` is removed. - Reactions are stored as `{support_index: {dir: {case: value}}}` rather than `{node_name: …}`. Every support is included, and all six directions are listed for frames. Values are floats, where before they were rounded to nearby integers. - `LoadTracer.issues` records every input that was corrected; the rundown warns once (`LoadTracerWarning`) when there are any. `LoadTracer.errors` records members that failed under `on_error="collect"`. - `expand_occupancy_loads` / `expand_reaction_loads` no longer take null-load arguments, and `LoadTracer.null_point_load` / `null_dist_load` are removed. - Only transfer loads from excluded members are dropped. Loads whose source reactions are ignored stay in `expanded_pm` without an expansion.