Skip to content

fix(abaqus): stop a symmetric section's zero product of inertia being read as missing data - #396

Closed
oleandor wants to merge 3 commits into
Krande:mainfrom
oleandor:fix/abaqus-general-section-properties
Closed

oleandor wants to merge 3 commits into
Krande:mainfrom
oleandor:fix/abaqus-general-section-properties

Conversation

@oleandor

Copy link
Copy Markdown
Contributor

The bug

A channel exported to Abaqus goes out roughly five times too stiff about its major axis, and the
adapy model is left corrupted for every export after it.

eval_general_properties fills in section properties a model does not carry, and decides "missing"
by testing <= 0.0. But every field of GeneralProperties defaults to None, so None is the only
marker for missing — and zero is a computed answer. Iyz is exactly 0 for every section
symmetric about an axis: box, tubular, I-profile, circular, flatbar, channel. Only calc_angular
returns a non-zero one. So the correct answer was treated as absent for nearly every profile that
reaches this function.

The substituted value made it worse. (Iy + Iz) / 2 is the largest a product of inertia may
legally be, so the fabricated Iyz immediately failed the positive-definiteness test just below, and
that branch inflated Iy as well. Measured:

UNP200x10   Iy 1.927e-05 -> 9.212e-05   (x 4.78)   Iyz 0 -> 1.049e-05
UNP300x15   Iy 8.063e-05 -> 4.515e-04   (x 5.60)   Iyz 0 -> 4.314e-05

Both numbers reach the *Beam General Section data line behind a log line whose own text admits
"this is not a validated method of solving this issue". A section five times too stiff in the wrong
plane is not something a reader of the deck would notice.

And Section.properties caches into _genprops; the function bound that cached object and assigned
to its fields. So writing an Abaqus deck permanently changed the model — a Sesam or IFC export
later in the same process inherited the inflated Iy. Cross-format contamination with nothing to see.

The fix

Missing means None; a computed zero is kept. Where Iyz genuinely is unknown the substitute is
0.0 — symmetric — rather than the extreme of its legal range.

Ix, Iy and Iz keep the or <= 0.0 test, because those cannot legitimately be zero for a real
cross-section. That asymmetry is the point rather than an oversight, and a test pins it.

A copy is returned (dataclasses.replace), so the model is never written to.

Impossible properties raise. Adjusting Iy by 10% until the inequality holds is how a section
becomes quietly stiffer. With a real Iyz the inequality cannot fail for a physically possible
section, so a failure means the input is inconsistent, and the error names the numbers.

Evidence

  • 11 tests. Restoring the original behaviour in full fails 7 of them, including an end-to-end
    assertion on the number that reaches the deck's data line.
  • Each half of the defect was also mutated separately and is caught separately — either one alone is
    a no-op for a channel, which is why the pair had to be tested together rather than assuming one
    test covered both.
  • tests/core/ shows only the pre-existing environmental failures (8, matching clean main).
  • A Sestra-proven 344 MB Sesam deck still converts to two differing lines, the timestamp. That path
    does not call this function — it reads Abaqus and writes Sesam — but it is this project's standing
    regression gate and was run anyway.

Note on overlap

#386 touches this file but does not fix this — it carries the same if gp.Iyz <= 0.0 and the same
Iy inflation. So this is not duplicated work, though the two will conflict. Happy to rebase onto
#386 whenever it lands.


🤖 Generated with Claude Code

https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv

… read as missing data

A channel exported to Abaqus went out roughly five times too stiff about its
major axis, and the adapy model was left corrupted for every export after it.

`eval_general_properties` fills in section properties a model does not carry, and
it decided "missing" by testing `<= 0.0`. But every field of GeneralProperties
defaults to None, so None is the only marker for missing -- and zero is a
computed answer. Iyz is exactly 0 for every section symmetric about an axis: box,
tubular, I-profile, circular, flatbar, channel. Only calc_angular returns a
non-zero one. So the function treated the correct answer as absent for nearly
every profile that reaches it.

The substituted value made it worse. (Iy + Iz) / 2 is the *largest* a product of
inertia may legally be, so the fabricated Iyz then failed the positive-definiteness
test immediately below, and that branch inflated Iy as well. Measured:

    UNP200x10   Iy 1.927e-05 -> 9.212e-05   (x 4.78)   Iyz 0 -> 1.049e-05
    UNP300x15   Iy 8.063e-05 -> 4.515e-04   (x 5.60)   Iyz 0 -> 4.314e-05

Both numbers reached the *Beam General Section* data line behind a log line whose
own text admits "this is not a validated method of solving this issue". A section
five times too stiff in the wrong plane is not something a reader of the deck
would notice.

Section.properties caches into _genprops, and the function bound that cached
object and assigned to its fields. So writing an Abaqus deck permanently changed
the model: a Sesam or IFC export later in the same process inherited the inflated
Iy. Cross-format contamination with nothing to see.

Three changes:

  missing means None. A computed zero is kept. Where Iyz genuinely is unknown the
  substitute is 0.0 -- symmetric -- rather than the extreme of its legal range.
  Ix, Iy and Iz keep the `or <= 0.0` test, because those cannot legitimately be
  zero for a real cross-section; that asymmetry is the point rather than an
  oversight, and a test pins it.

  a copy is returned. dataclasses.replace, so the model is never written to.

  impossible properties raise. Adjusting Iy by 10% until the inequality holds is
  how a section becomes quietly stiffer; with a real Iyz the inequality cannot
  fail for a physically possible section, so a failure means the input is
  inconsistent and the error says which numbers.

11 tests. Restoring the original behaviour in full fails 7 of them, including an
end-to-end assertion on the number that reaches the deck. Each half of the defect
was also mutated separately and is caught separately -- either one alone is a
no-op for a channel, which is why the pair had to be tested together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
@github-actions

github-actions Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

PR Review

👋 I checked your PR and found no issues. Thanks!

  • ✅ PR title is ok
  • ✅ Exactly one release label
  • ✅ SOURCE_KEY secret is set
  • ✅ Calculated next version: "0.91.1"

@github-actions

github-actions Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

🚀 Profiling Results (Top 20 most expensive calls)

Function Calls Duration (s)
<built-in method builtins.exec> (~:0) 1372 16.0507
<module> (<string>:1) 1 16.0507
test_bench_batch_tessellate (tests/profiling/test_cad_backend_bench.py:63) 1 5.0148
run (tests/profiling/test_cad_backend_bench.py:73) 6 4.8876
batch_tessellate (src/ada/visit/tessellate.py:1102) 1446 4.8866
test_build_big_ifc_pipe (tests/profiling/test_ifc_creation.py:48) 1 4.5759
tessellate_geom (src/ada/visit/tessellate.py:830) 1440 4.4496
__truediv__ (src/ada/api/spatial/part.py:2046) 8 4.3534
tessellate_occ_geom (src/ada/visit/tessellate.py:684) 1440 4.0505
tessellate_shape (src/ada/visit/tessellate.py:554) 1440 4.0401
tessellate (src/ada/cad/__init__.py:1187) 1440 3.2936
to_ifc (src/ada/api/spatial/assembly.py:325) 5 3.2870
add_object (src/ada/api/spatial/part.py:309) 1201 3.1676
add_pipe (src/ada/api/spatial/part.py:164) 200 3.1534
sync (src/ada/cadit/ifc/store.py:200) 5 3.0620
sync_added_physical_objects (src/ada/cadit/ifc/write/write_ifc.py:92) 5 3.0201
add (src/ada/cadit/ifc/write/write_ifc.py:582) 2200 2.9616
add_section (src/ada/api/spatial/part.py:294) 801 2.7243
add (src/ada/api/containers/sections.py:139) 805 2.7233
equal_props (src/ada/sections/concept.py:100) 241399 2.4901

@oleandor

Copy link
Copy Markdown
Contributor Author

Correction to the test evidence in the description above.

It says the suite shows "only the pre-existing environmental failures". That was wrong, and the error was mine: I ran the test suite with PYTHONPATH set but without the environment's native DLL directories on PATH, so code paths that delay-load a native library failed. The freetype and trimesh failures I attributed to the environment were artifacts of that invocation.

Run correctly (the environment's Library/bin on PATH, as pixi run test-core does), the true figures are:

passed failed
clean main at 0.88.0 3852 0

So the baseline is zero failures, not eight or twelve, and this branch adds none. The comparisons in the description remain valid — clean main was run with the same (broken) command, so it was like-for-like — but the absolute numbers quoted there should be read as artifacts of my test invocation rather than properties of the repository.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv

…still reaches GENERAL

The "Update branch" merge brought in Krande#386, whose Abaqus writer traces a channel as
`section=ARBITRARY` -- three walls by centreline, exact -- instead of sending it down the
GENERAL path. So the one end-to-end test here, which used a channel *because* it was the profile
that reached `*Beam General Section`, now fails on its own precondition, on all three CI
platforms and in the conda build.

The defect this PR fixes is untouched by that: it lives in `eval_general_properties`, which every
`*Beam General Section` still goes through. The test moves to the case that reaches it today, a
section declared GENERAL carrying a real channel's properties with its computed zero `Iyz`, and
additionally asserts that the zero survives to the data line. Same numbers the measurement was
made on; the same file as on Krande#398, where it has been green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
@Krande

Krande commented Sep 27, 2026

Copy link
Copy Markdown
Owner

Folded into #407.

This branch was merged in as-is, with its own merge commit, so every commit and its authorship
survive in that PR's history. Nothing here was rewritten or dropped.

#407 collects the six format-level FEA PRs (#394, #395, #396, #399, #400, #404) into one, since
adapy is squash-only and each merged feat:/fix: cuts a release. Closing this in favour of it.

@Krande Krande closed this Sep 27, 2026
Krande added a commit that referenced this pull request Sep 27, 2026
…nd verify plates through decks (#407)

Folds the six format-level FEA PRs into one, drops the Abaqus/CAE stack, and gives the verification
report a plate case built through the deck writers.

Folded: #394 (*Tie -> BLDEP facet interpolation), #395 (Sesam super element number), #396 (a
symmetric section's zero product of inertia), #399 (.gnx as a first-class format), #400 (Sesam
settlement, one BLDEP term per dof pair, instance-qualified *MPC), #404 (surface pressure as BEUSLO).

Dropped: #398, #401, #402, #403, #405 -- src/ada/cadit/cae, Part.to_abaqus_cae_script, its tests and
verification/genie_vs_abaqus, which drives `abaqus cae noGUI=`. adapy supports formats, not APIs into
proprietary software, and a verification entry only a CAE licence can reproduce verifies nothing
anyone can check. Salvaged from it, because it is format-level: a T-profile now reaches an Abaqus
*Beam Section as section=I with the bottom flange zeroed (it had no mapping at all), and Calculix
converts a T to a general section as it already did an I.

New: the plate strip case -- an ada.Plate meshed by the meshing module (gmsh), written as a deck per
solver, checked against 5 q L^4 / (384 D) and f_n = n^2 pi / (2 L^2) sqrt(D / (rho t)). Code_Aster
lands on the eigen closed form to six digits.

Writer gaps it exposed, each found by running it rather than reading:

* Calculix had no pressure load at all; it now writes *DLOAD ... P. Measured on ccx 2.23 that P, P1
  and P2 are bit-identical on a shell, so the label carries no face and only the sign can.
* Code_Aster's pressure had never run: FORCE_FACE with FY is a traction along global Y, not a normal
  pressure, and GROUP_MA named the surface, which has no MED counterpart -- every attempt stopped at
  <EXCEPTION> <MODELISA7_77>. Now FORCE_COQUE=_F(PRES=...) over the element sets, via the new shared
  ada.fem.surfaces.pressure_elsets.
* Code_Aster refused any eigenvalue analysis with more than one Bc; ASSEMBLAGE's CHARGE takes a tuple.
* The Sesam BEUSLO pressure sign was inverted relative to Abaqus, Calculix and Code_Aster. The
  reference it was tuned against applied its load along global -z on the grounds that -z was
  "against the plate's +z normal"; the strip's element normals are all -z (measured, all 128), so the
  reference ran along the normal and the test that pinned it never checked which face was named.
  Code_Aster reproduces the Sestra magnitude to eight digits with the opposite sign. The measured
  Sestra numbers are kept exactly as measured; only which face they belong to changed. The Sestra leg
  runs only where Sestra is installed, so a licensed re-run remains the confirmation worth having.

Co-Authored-By: oleandor <oleandor@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXGBkUbGK6oYWxm5R2WYVB
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants