Skip to content

feat(verification): a plate case for the Sestra-vs-Abaqus check, compared between convergence extrapolants - #403

Closed
oleandor wants to merge 39 commits into
Krande:mainfrom
oleandor:feat/genie-vs-abaqus-plates
Closed

oleandor wants to merge 39 commits into
Krande:mainfrom
oleandor:feat/genie-vs-abaqus-plates

Conversation

@oleandor

@oleandor oleandor commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

A plate case for verification/genie_vs_abaqus: the same shell model solved in Sestra 11.3-00 (FQUS) through the Sesam writer and in Abaqus 2025 (S4R) through the CAE concept-model writer of #401, both against closed forms — and, because shells do not agree node-for-node the way the portal frame's beams did, compared as a mesh-convergence bracket rather than a single-mesh table. --case plate on the existing run_comparison; the portal frame is untouched and still passes at the same 2.468646e-02 sway.

Stacked on #405 (edge/face regions) → #402 → #401 → #398 → #396; GitHub cannot express a stack whose base lives on a fork, so those commits appear in this diff. Review from a6dbf3420 onward — six commits.

The case

A 4.0 × 0.5 m strip, 10 mm, S355, 1000 Pa, simply supported on the two short edges with u2 = 0, ur1 = 0 on both long edges (cylindrical bending). Two variants: bare, and with a 10 × 45 mm flat bar on the centreline — a Stringer in CAE, a BEAS sharing the shell's nodes in Sesam. Three seeds a factor of two apart. Both meshers land on the same structured grid — 165 / 585 / 2193 nodes, 128 / 512 / 2048 shells, 32 / 64 / 128 beams, identical on both sides at every density — though correspondence is still established by position, not assumed.

Convergence, measured (mid-span u3, m)

Bare, closed form 5qL⁴/(384D) = 0.173333333, D = E t³/(12(1−ν²)):

elements per span Sestra FQUS rel Abaqus S4R rel
32 0.1731979102 7.81e-04 0.1730654836 1.55e-03
64 0.1732994765 1.95e-04 0.1732686013 3.74e-04
128 0.1733248681 4.88e-05 0.1733193845 8.05e-05
Richardson 0.1733333194 8.0e-09 0.1733363138 1.72e-05

Stiffened, parallel-spring 5(qb)L⁴/(384(EI_p+EI_b)) = 0.065200287:

Sestra rel Abaqus rel
32 0.0651635677 5.63e-04 0.0651114956 1.36e-03
64 0.0652009770 1.06e-05 0.0651863739 2.13e-04
128 0.0652091727 1.36e-04 0.0652047023 6.77e-05
Richardson 0.0652114719 1.72e-04 0.0652106428 1.59e-04

Observed order from the three meshes: Sestra 2.000 / 2.19, Abaqus 2.000 / 2.03 — second order on both sides, read off rather than assumed, which is what licenses the extrapolation. Two closed forms fall out of the same solves unaimed: the support rotation qL³/(24D) = 0.138666667 against 0.138666745 (Sestra, 5.6e-08) and 0.138666648 (Abaqus, 1.3e-07); the stiffness ratio EI_p/ΣEI = 0.376156 against 0.376220 / 0.376209 — 2.66× stiffer, by a hand number.

The tolerance, and how it was set

PLATE_REL_TOL = 1e-04, from the measurement: at the coarsest mesh the two solvers differ by 7.65e-04 and at the finest by 3.16e-05 with nothing about the translation changing, so a single-mesh tolerance would have to be ~9e-04 — 50× the converged difference; the extrapolants agree to 1.72e-05 (bare) and 1.27e-05 (stiffened), worst significant component anywhere 1.81e-05; 1e-04 is 5.5× that and 100× below the smallest defect it exists for. The single-mesh table is printed as diagnosis, never as the verdict. The bare strip's width spread is exactly 0.0 in both solvers; the stiffened strip's 8.1e-05 in both is physical (the plate spans transversely between the bar and the held edges).

Re-run independently by the session opening this PR: exit 0, "plate case passed: both variants agree at rel 1.0e-04 after extrapolation", worst components 1.805e-05 (bare) and 1.755e-05 (stiffened); self-test 109/109; wrapper 10/10.

Two writer gaps found on the way — reported, not patched here

  1. A pressure load reaches the Sesam deck as nothing. load_str reports it OMITTED and returns ""; there is no BEUSLO/BELOAD in the Sesam writer. The deck is written, Sestra reports "Execution completed successfully" on an unloaded structure — and two such runs agree perfectly with each other. The Sestra side here gets the exact consistent nodal load (∫N_i dA = A/4 for a bilinear quad, so qA/4 per node is the pressure's vector), stated in the docstrings and checked: three Load objects summing to −2000.000 N, each solver's reaction total 2000.0. A BEUSLO writer is in progress separately.
  2. The CAE writer refused a support along an edge — analysis._resolve_region resolved only to a vertex. Closed by feat(abaqus): a support along a plate edge or over its faces reaches CAE as a geometric region #405, on which this now sits: the strip's three supports travel through the writer's own Bc records as edge regions, and the workaround driver this case first shipped with is deleted. Every Abaqus number in the tables above is bit-identical between the driver's supports and the writer's (e.g. bare seed 0.125: −0.17306548357009888 both ways; reactions, node and element counts byte-for-byte the same). The measurement that motivated the box-based location is kept where it informs: on the stiffened strip a supported end is two collinear edges of 0.25 m, and findAt at the midpoint returns one of them.

Also: sestra_runner's "gap 2" note is out of date (load_force loops every member of the set), corrected.

Verification of the verification

20 mutations, 20 caught — five of them written because the first pass of a check passed with the bug present, and each fixed in the check rather than the tolerance: a turn-around sequence at ratio exactly 2 was rejected by ORDER_BAND so the sign clause was untested; a list mixing two variants also turns around, so deleting extrapolate's structural check changed nothing (now a distinct NotASequence); an unwrapped re-raise kept the type and lost the address; unit cells are the one case where assuming mesh_size² gives the right tributary area; and dropping ur1 from the CYL support — 9% on the answer — was caught by nothing, which is what check_plate_boundary_semantics now exists for. The rest: D without 1−ν², EI_bar from Iz, the stiffener ratio check disabled, a 10% cylindrical tolerance, a magnitude-only reaction check, PLATE_REL_TOL raised to 1e-02 (including "the coarsest mesh must fail"), assert_has_shells counting all elements, qA instead of qA/4, the connectivity check weakened, assert_probes_are_seeded disabled, a fixed rotation at a "simple" support, the Abaqus route handed the Sestra load form.

Self-test 35 → 116 checks, MINIMUM_CHECKS raised with it, all in tests/core/test_genie_vs_abaqus_selftest.py. Suite tests/core/: 4716 passed, 22 skipped, 0 failed; lint clean.

Deliberately left out

  • A fourth, coarser mesh — three densities a factor of two apart is what the order estimate needs.
  • Stresses and section forces; behaviour at a mesh too coarse to be converged beyond the printed single-mesh table (which shows S4R the softer at every density).
  • A curved plate case — plates.py supports one; its closed form is not clean; a workstream of its own.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv

oleandor and others added 29 commits September 26, 2026 09:15
… 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
…E writers

`write_sections.py` held the only Section -> Abaqus cross-section table, and the
CAE script writer needs the same decision expressed differently: the `section=`
keyword and data line on one side, the `mdb.models[..]` profile class and its
arguments on the other. Two tables would drift, and sections they disagreed
about would still analyse.

So `ProfileSpec` / `profile_spec()` in `ada.sections.profiles` now carry both,
and the INP writer reads them instead of its own branch-per-type. Every one of
the ten `BaseTypes` is mapped, so there is no fallback branch left.

Measured against an Abaqus 2025 kernel rather than recalled, because three of
the results are not what the API reads like:

* Abaqus/Standard has **no `section=CHANNEL`** ("Illegal value \"CHANNEL\" for
  parameter \"section\"") even though Abaqus/CAE writes one. So a channel keeps
  the general-section fallback in the INP and gets a real `ChannelProfile` only
  in CAE -- which is why `inp_dims` and `cae_kwargs` are separate fields.
* There is **no `section=T`**: CAE spells a T as `section=I` with the bottom
  flange zeroed and the flange in the b2/t2 slots. TPROFILE used to raise; it
  now writes, and a deck of all ten types passes `abaqus datacheck` with zero
  errors and a total mass matching adapy's own area computation to 7 figures.
* `ArbitraryProfile` is **thin-walled**, not a filled polygon (0.2x0.2 outline
  at t=0.01 measures 0.006 m3 against 0.04 m3 filled), so POLY cannot use it.
  POLY's target is a generalized section, and it is refused until
  `calc_poly` stops being a stub that returns zeros -- run through the
  substitution those zeros become Ax=0 with the stiffness of a 1.2 m solid
  square, which would write and analyse.

`eval_general_properties` moves to the same module unchanged, since the CAE side
needs it too, and is re-exported from `write_sections` for its callers.

The INP output is byte-identical for all eight types that already worked --
verified by running the pre-refactor writer out of git on the same sections,
with every dimension distinct so a swapped pair cannot hide behind a symmetric
profile. The Sestra-proven deck still converts to 2 differing lines (the
timestamp). 24 tests; each of 13 mutations fails one, and the b1/b2 swap is
caught only by the asymmetric section, not by the IPE300.

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

adapy's only Abaqus output is an INP. Importing one into CAE was measured: materials,
typed profiles, beam sections, section assignments and sets all arrive as first-class
model objects, but the geometry arrives as an orphan mesh -- edges 0, faces 0, cells 0.
Nothing to re-mesh, nothing to attach a brace to, nothing to edit.

Part.to_abaqus_cae_script() emits the geometry instead: one CAE part per adapy part, one
WirePolyLine per beam from Beam.axis_global(), each member located by bounding cylinder
and given a set, a section and an N1_COSINES orientation. Profiles, sections and
materials come from the section->profile mapping shared with the INP writer, so the two
paths cannot drift.

Phase 1 is straight beams only, and the point of it is what it refuses:

* every edge must end with exactly one section assignment, asserted inside the emitted
  script against the kernel's own sectionAssignments. A brace landing mid-span splits the
  through member (measured: 1 edge -> 3), and a cylinder that misses a sub-edge, or a
  duplicated wire, shows up here and nowhere else;
* Beam subclasses are refused by exact type, not isinstance -- a BeamRevolve drawn as its
  straight chord is a model that opens, meshes, solves and is wrong;
* beams with a non-null e1/e2 are refused: a wire drawn node to node discards the offset
  in silence;
* endpoints come from Beam.axis_global(), whose docstring already says exporters must
  share it, and the emitted script re-checks its own bounding box against the corners
  adapy computed, so a lost Part placement cannot pass unnoticed;
* <stem>.cae_build_result.json records what was built, what was skipped and why, and any
  error. Measured: sys.exit(1) under `cae noGUI=` leaves the run reporting 0, os._exit(1)
  does reach the process, and abq2025.bat flattens it either way -- so the sidecar is the
  signal, not an exit code.

Names are sanitised for the one character CAE rejects (a dot), collisions fail rather
than silently replacing an object, and <stem>.name_map.json records any rename.
A generalised section integrates BEFORE_ANALYSIS with its own elastic constants:
DURING_ANALYSIS writes "Generalized Profile cannot be used with this section" into the
exported INP, which builds in the GUI and cannot be solved.

Verified against a real Abaqus 2025 kernel, not only in tests: the frame builds 6 edges
from 5 members (the girder split by the brace), all 6 sectioned, 5 orientations, bounding
box error 0.0; deleting one member's assignment fails the build with the two unassigned
sub-edge indices; and a meshed export carries *Beam General Section with the right data
line and no ERROR. 64 tests, each verified to fail without its change via 31 mutations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
…rientation against the kernel

The bar this clears is `tests/core/cadit/e3d/test_aveva_e3d_mac.py`, the only test of this repo's
other script-emitting writer: it builds a model, calls the writer, and prints. It asserts nothing.

Licence-free, so it runs in CI:

* `cae_script_graph.py` reads an emitted script with `ast.parse` and asserts the *graph* inside it --
  every section assigned to a region built from a bounding-cylinder lookup, every profile, material
  and section defined before it is used, every part instanced exactly once, no name emitted twice,
  no name carrying a dot, every `n1` unit and perpendicular to the member it orients, a cylinder that
  actually lies on its member and overshoots its ends, a non-zero exit and a result sidecar, and a
  py2.7 syntax floor. This is the rejected recording stub's assertions without the fake API: nothing
  here pretends to know what `WirePolyLine` does.
* `test_cae_graph_checks_have_teeth.py` injects 23 single defects into a specimen script and asserts
  the named check rejects each one on its own. It asserts a green baseline first, that every anchor
  matches the specimen exactly once, and that every check in the pass is exercised -- the three ways
  a mutation harness in this project has previously reported success without testing anything.
* `test_cae_orientation_convention.py` pins the CAE writer's `n1` to the *INP writer's* `n1`, over
  vertical / horizontal / skew / explicit-`up` members, so a third orientation convention cannot
  appear. It also records a measured wart: the INP writer emits `cross(local_z, xvec)` unnormalised,
  0.6695 long for a skew member, so the two writers agree on direction and differ in length.
* `test_cae_emitted_script.py` holds a reviewed golden of the emitted text, plus determinism, unit
  scaling, dot sanitisation and the phase-1 refusals.

Licensed, skipped rather than failed when no Abaqus is installed:

* `abaqus_runner.py` drives `abq cae noGUI=`, lifts the script's `print` output out of `abaqus.rpy`
  (it never reaches stdout) and decides failure from the output rather than the launcher's exit
  status, which is 0 even when the CAE process died.
* the acceptance check loads a cantilever along adapy's `n1`, then along its `n2`, and requires the
  tip-deflection ratio to be the section's own `Iy / Iz` -- a ratio, so no units or material need to
  agree. Measured 13.2754 against 13.2718 for an IPE300. An unequal-leg angle is in the model
  because its product of inertia is the only thing that can catch a mirrored local frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
It was named after the workstream that wrote it, which tells a later reader
nothing about its subject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
…, and read failure from the sidecar

Four things WS-B measured against the real kernel, and one false positive they found in my own check.

**The false positive.** `check_members_are_located_by_a_cylinder_spanning_them` matched a cylinder to
the first collinear segment it met and then judged that segment against the end caps. Stacked columns
are ordinary and each lies on the other's cylinder axis, so whenever the regions were not emitted in
the same order as the wires it rejected a perfectly good script with "does not overshoot its member's
ends" -- pointing at the wrong member, with the wrong reason. A segment that sticks out of the caps is
now treated as *not this cylinder's member*, the caps are reported only once nothing fits, the tightest
fit wins where several do, and every message names the set name.
`test_a_valid_script_is_accepted[stacked_columns_in_reverse_order]` is the case; it fails against the
previous checker with exactly that misleading message.

**The exit status is useless in both directions, so the runner no longer consults it.** `abq2025.bat`
returns 0 whatever the CAE process did, and a `sys.exit(1)` inside a `noGUI=` script is treated by CAE
as a clean finish -- process status 0, no `Abaqus Error` line anywhere. `CaeRun.failure_signals` now
polls four independent signals and names the ones that fired: the timeout, the (unreliable) status,
`Abaqus Error` on stdout, the writer's `ADAPY-CAE BUILD FAILED` banner, and any
`<stem>.cae_build_result.json` reporting `ok: false` or an error.
`test_a_failure_that_leaves_through_sys_exit_is_still_detected` pins the measurement: a broken build
that exits via `sys.exit` is caught by the sidecar and by nothing else.

**Set names are unique globally, not per part.** The writer refuses cross-part duplicates because its
sidecar keys `edges_per_member` by set name, so `check_names_unique` checks them globally and
`_region_key` is now the bare set name -- which is also the elset name in an exported INP.

**A duplicate `Part` or `Set` name does not raise: CAE replaces the object** and invalidates handles to
the old one. Recorded in `check_names_unique`, because it means "the kernel would have caught it" is
not available as an argument anywhere in this verification.

Also: perpendicularity stays at 1e-9 because it is measured against the cylinder axis, which comes from
`Beam.axis_global()`. `Beam.xvec` is rounded to 7 decimals, so `dot(yvec, xvec)` reads 4.5e-8 on a
(6,3,4) member -- noted where the tolerance is defined so nobody re-points it at `xvec`.

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

Guard 1 claimed to catch both connectivity failure modes and caught neither.
Measured on Abaqus 2025 with the writer's own wire calls:

    T-joint, IMPRINT (correct)        edges=3 vertices=4     guard 1 passes
    T-joint, SEPARATE (disconnected)  edges=2 vertices=4     guard 1 PASSES
    brace 1e-6 off the girder line    edges=2 vertices=4     guard 1 PASSES
    collinear pair, touching          edges=2 vertices=3     guard 1 passes
    collinear pair, 1e-6 apart        edges=2 vertices=4     guard 1 PASSES

Every one of those reaches the guard with every edge carrying exactly one
section, so a pile of loose sticks got the same verdict as a frame.
`edges_per_member` was recorded and never compared to anything.

So compute the expected topology on the adapy side, where every endpoint is
known, and have the emitted script assert the kernel against it: one sub-edge
per member plus one per other member's endpoint landing strictly inside it, the
part's total edge count, and the part's vertex count. The vertex count is not
redundant -- two collinear members that failed to join change nothing else.

Crossing policy: REFUSED, not asserted. CAE imprints a crossing exactly as it
imprints a real joint (measured: an X builds 4 edges / 5 vertices), so the
emitted model would transfer moment where the source models no joint. Asserting
it would make the change known without making it right, and mergeType is
per-wire rather than per-pair, so "join the real joints but not this crossing"
cannot be said in one CAE part. The in-kernel check is the backstop for a
crossing the refusal cannot see. Collinear overlap is refused for the same
reason: measured 3 edges where per-endpoint counting predicts 4.

Tolerance: adapy's own Config().general_point_tol (1e-4), deliberately looser
than CAE's merge tolerance, which is 1e-6, absolute, and scale-independent
(measured identical at part sizes 0.004, 4 and 4000 -- ACIS SPAresabs, not a
function of the model, unlike the cylinder fractions). Closer than 1e-6 both
join; further than point_tol neither does; in between adapy joins and CAE does
not, which means the INP route would build the frame connected and the CAE
route would not -- so the build fails and names the member rather than shipping
an inert brace. Nothing is ever snapped.

Also:

* unit_scale != 1.0 is refused. It multiplied coordinates and profile
  dimensions and left E and the density alone: unit_scale=1000 emitted
  IProfile(h=300.0) beside Elastic(table=((2.1e11, 0.3),)), 1e6 too stiff, with
  nothing reporting it. No single factor fixes it (E ~ s^-2, density ~ s^-3
  only once a mass unit is chosen; N/mm/s wants tonnes). The test that used to
  assert the scaling is kept and now asserts the pair it never looked at.
* Guard 7: no planned name may already exist in the target model. CAE replaces
  a reused name instead of refusing it, so a second run in one GUI session
  quietly swapped every part. Checked before anything is built.
* Guard 1 recorded into the per-part guards dict by assignment, which dropped
  the new connectivity verdict from the sidecar. Now update().
* Corrected the stale claim that an asymmetric section makes a sign flip in n1
  visible. It cannot: every second moment is quadratic in position and so
  invariant under a 180 degree rotation.

Verified against the real kernel: all three broken models (disconnected,
1e-6 near miss, crossing) fail with the member named, and the correct frame
still builds with expected_edges 6 / expected_vertices 7 in its sidecar.
Thirteen mutations of the new code were each killed by the new tests.

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

CAE has a ChannelProfile and it is a dead end. Abaqus/Standard rejects the
section=CHANNEL that Abaqus/CAE's own exporter writes for one -- measured again
here, through abaqus datacheck on a deck adapy produced: 7 errors, starting with
Illegal value "CHANNEL" for parameter "section" and ending with every element
missing a beam section. CAE will not even integrate such a profile:
getMassProperties() returns mass=None. o=0 and o=0.05 fail identically, so no
argument of the class avoids it.

So CHANNEL now maps to GeneralizedProfile on the CAE side too, carrying the very
numbers the INP side has always written. The same datacheck on the deck CAE
exports from that completes with zero errors. A shape that only renders is worth
less than a section that analyses, and nothing keeps a channel's outline on
either route any more -- which the log line now says once, instead of the INP
route admitting a loss the CAE route pretended to avoid.

The class is refused structurally rather than by omission:
CAE_PROFILE_CLASSES_THE_SOLVER_REJECTS makes constructing a ChannelProfile spec
raise with the datacheck error in the message, because "CAE has a
ChannelProfile, use it" is a one-line change anybody would consider reasonable.
Its probed argument list goes with it; the undocumented `o` was asserted and
never proved, and could not be, since nothing downstream of such a section will
measure anything.

Two new licensed tests, and neither could have been replaced by a cheaper one:

- test_a_channel_model_passes_abaqus_datacheck runs a channel all the way
  through the solver's input processor. No test that reads the emitted script
  and none that interrogates the CAE model afterwards could see this failure --
  every one of them was green on the mapping that produced it.
- test_the_wider_flange_sits_below_the_beam_axis settles the sign of n1 against
  adapy's own geometry. Second moments are quadratic and so invariant under a
  180 degree rotation, which is why no deflection measurement can catch a sign
  flip; first moments are not, and CAE reports a centre of mass for a sectioned
  wire with no analysis job at all. An I-section with w_btn 0.41 > w_top 0.31
  puts its centre of mass at -0.0441890415587341 along up, against the centroid
  of the outline adapy draws for it, -0.044189041558734175. The cross-writer
  equality test only says the CAE writer agrees with the INP writer, whose own
  sign has never been checked against anything physical, so a shared error would
  pass it in silence. A companion test builds the same member with n1 negated
  and asserts the mirror image, so the measurement's sensitivity is asserted
  rather than assumed.

Verified by reverting each change: reinstating ChannelProfile kills the two
profile-spec tests and the probed-argument table, and fails the licensed
datacheck with the seven errors above; emitting a negated n1 kills both
centre-of-mass tests. The INP golden table stays green throughout, which is what
shows the INP side is genuinely untouched by this decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
…mparator that fails loudly

One concept model, two solvers, compared -- plus a closed form so that "both
agree" and "both right" stay separate claims. This is the Sestra half and the
framework; the Abaqus half plugs in behind a declared contract.

verification/genie_vs_abaqus/:

* model.py -- the portal frame, written once. Both solver inputs are derived
  from this one Assembly, so the comparison tests the translation and not two
  hand-built models. A tube section (Iy == Iz) keeps beam orientation out of
  the first frame; slender members keep the shear term to 0.63%; a symmetric
  pair of loads makes the closed form exact rather than approximate.
* hand_check.py -- slope-deflection sway, Euler-Bernoulli and Timoshenko. Both
  limit cases (rigid girder, no girder) are verified exactly in selftest.
  The criterion is the *bracket* between the two forms, not either one: a
  shear-rigid element such as Abaqus B33 is correct and would fail a
  Timoshenko-only check.
* displacements.py -- the solver-neutral exchange format, and the answer to
  node correspondence: match by position at points both meshers must seed,
  never by node id. Zero matches raises, two matches raises.
* sestra_runner.py -- adapy -> Sesam FEM -> Sestra.exe -> .SIN -> table, with
  Sestra found through adapy's locator and the run verified against
  SESTRA.MLG rather than trusted. Its docstring records, measured, what adapy
  can and cannot write into a Sesam deck.
* compare.py -- per-point differences against a budgeted 1% tolerance, with
  the three rules that stop a "0 vs 0, agree" pass: same probe set, non-zero
  signal, minimum probe count. All three raise.
* abaqus_runner.py -- the plug point: the contract, and the six things the
  Abaqus side must get right, each sized by how far it moves the answer.
* selftest.py -- 21 checks, no solver needed, that every guard above actually
  fires.

Measured on Sestra V11.3-00: top-corner sway 2.468646e-02 m against a
Timoshenko closed form of 2.467747e-02 m, agreeing to 3.6e-4.

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

Phase 1 refused all four Beam subclasses and every non-zero e1/e2. Three of the
four carry the exact curve, and a constant offset is a native Abaqus keyword, so
both refusals were wider than the evidence.

Curved members. BeamCurved's curve3d is the ngeom curve off the ACIS body,
BeamRevolve's is a centre/axis/radius arc, so nothing is fitted: the curve is
sampled and drawn as WireSpline through those points. The sample spacing is a
turning angle, not a point count, because the angle is a property of the shape
and survives a change of units -- measured on Abaqus 2025, the built spline's
arc length converges as the cube of that angle (0.785 rad -> 1.3e-02 relative,
0.049 -> 2.6e-06), and every sample count from 3 to 65 built one edge.

A curve cannot be located by a bounding cylinder: one round a quarter arc's chord
returns 0 edges, because getByBoundingCylinder returns only fully contained edges
and the arc bulges 0.59 units outside its chord. So a curved member is located by
findAt at every interior sample point -- the call the straight path deliberately
avoids -- and the emitted script asserts all of them lie on ONE edge whose arc
length matches the curve's to 1e-04 relative. Measured on the demo models:
2.59e-06 and 2.89e-07.

The topology guard predicts exactly one sub-edge per curve, and that is a
prediction rather than an exemption. A member landing on a spline's interior does
imprint it (measured: 2 edges/3 vertices -> 3 edges/4 vertices), and where along
the spline CAE puts that vertex is the spline's own parameterisation's business,
so it is refused at plan time rather than guessed. Every model the writer accepts
therefore still has a stated sub-edge count for every member: a curve never turns
the per-member guard off for its part. The pair scan's bounding boxes are taken
over the sampled path, since an arc reaches outside the box of its own ends.

Curves also bring an orientation hazard a straight member cannot have: N1_COSINES
gives Abaqus one vector for the whole member and it projects that perpendicular to
each element's tangent, so a curve whose tangent turns onto its own n1 has nothing
left to project. Refused, with the sin of the closest approach in the message.

BeamTapered stays refused, now for a demonstrable reason: CAE accepts
beamShape=TAPERED and reads it back, and writing the INP segfaults the kernel.
Isolated so the taper is provably the cause and the curve provably innocent --
'straight_constant -> WROTE OK' against 'straight_TAPERED -> *** ABAQUS/ABQcaeK
rank 0 encountered a SEGMENTATION FAULT / error code 11 (0XB)'. The refusal
message carries that citation.

Constant eccentricity. adapy's e1 is a global vector; Abaqus' offset is a 2-tuple
in the section's own (n1, n2) axes, so the conversion is a projection onto
n1 = beam.yvec and n2 = t x n1. The SIGN was measured, not derived:
getMassProperties() cannot see an offset at all -- mass and centre of mass are
identical for (0,0), (0.3,0), (0,-0.4) and (0,0.4) -- so it was pinned against
adapy's own *MPC BEAM route by the solver. A cantilever under an eccentric axial
tip load, for e=(0,0,-0.4):

    MPC, section at node + e        tip U = (4.1813789E-02, 0, -1.9071177E-01)
    *Beam Section Offset  0.,-0.4         = (4.1813789E-02, 0, -1.9071177E-01)
    *Beam Section Offset  0., 0.4         = (4.1813789E-02, 0,  1.9071177E-01)

Identical to every printed digit for e.n2, exactly sign-flipped for -e.n2. So the
CAE route puts the section where the INP route puts it, with no extra nodes and no
constraints.

A generalized section needs a different keyword, and that is measured too: an
integration=BEFORE_ANALYSIS section REFUSES beamSectionOffset with
'TypeError: keyword error on beamSectionOffset' from the constructor and from
setValues alike, and takes centroid instead (written *Centroid). Without that a
channel -- which both Abaqus routes must carry as a generalized section -- could
not carry an offset at all. *Centroid reproduced the same MPC reference exactly.

Still refused: a varying offset (one 2-tuple cannot say both ends; measured, every
offset in adapy's Genie corpus is constant), an axial component (a section offset
cannot lengthen a member), and an offset on a curved member (the (n1, n2) frame
rotates along the curve).

files/fem_files/sesam/varying_offset/beams_constant_offset.xml used to raise
UnsupportedBeamError and now builds in the kernel: 7 edges, 14 vertices, every
edge sectioned, five *Beam Section Offset cards and one *Centroid in the exported
deck.

A model with no curve and no offset emits what it emitted before, minus a schema
bump and two sidecar keys: the helpers and the tables are emitted only when the
model has something for them.

Two defects found while building this, both by tests that now pin them: the curve's
arc length was being compared against the chord sum rather than the curve (a chord
polyline is 1.0e-04 short at the emitted sampling, forty times the spline's own
error), and the textbook clamped segment/segment solve reports the wrong distance
for a zero-length partner segment, which would have made the crossing refusal miss
a contact in silence.

test_cae_writer.py's refused-beam list was being built inside a parametrize
decorator, so it ran at import: a failure to construct any one object took out
collection for the whole session rather than failing one test. Demonstrated and
made lazy.

Verified: 24 targeted reverts, every one caught by at least one test; tests/core
4150 passed, 0 failed; the licensed acceptance suite 13 passed against a real
Abaqus 2025; black, isort and ruff clean at 120.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
Curved beams and constant eccentricities are supported; the docstring still said
they were refused. It now also says why each remaining refusal exists, since
'refused' without a reason invites someone to lift it.

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

Both were mine and both asserted a precondition that 0.90.0 no longer meets.
Neither defect they guard has gone anywhere, so neither test is deleted.

`test_a_channel_in_a_written_deck_carries_its_true_inertia` asserted that a
channel goes out as `*Beam General Section`. On 0.90.0 it does not: Krande#386 gave
the writer `section=ARBITRARY`, which traces a channel's three walls by
centreline and thickness, so no channel reaches `eval_general_properties` any
more. The defect it guards is in that function, which every `*Beam General
Section` still goes through, so the test moves to the case that reaches it
today -- a section *declared* GENERAL. Its properties are a real channel's, so
the numbers are the ones the original measurement was made on. Measured on
0.90.0 with the fix reverted, on this exact deck:

    Iy   1.9270167e-05  ->  9.2119969e-05   (x 4.78)
    Iyz            0.0  ->  1.0488131e-05   (fabricated from a computed zero)

and written into the cached GeneralProperties as well, so a later export in the
same process inherited them. Reverting `eval_general_properties` in full fails
6 of this file's 11 tests, the re-pointed one among them.

`test_an_inp_exported_from_cae_reads_back_into_adapy` matched members by the
read-back section's *name*. Krande#386's reader takes that name from the `** Section:`
comment Abaqus/CAE writes above the block, so a section now comes back as
`sec_BG200x200x10_S355` instead of inheriting its elset's name. That is the
right way round -- a section renamed after one of its sets was the bug being
fixed -- and it means the elset is what still carries the member's name, so the
test matches on that instead. The KeyError it produced was the test's
assumption failing, not the round trip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
Krande#386 gave the INP writer `section=ARBITRARY` for a channel -- its three walls by
centreline and thickness -- and it is right: a rolled channel is thin-walled, so
three segments describe it rather than approximate it. That left this branch's CAE
side on `GeneralizedProfile`, which keeps five integrated numbers and throws the
outline away. Two different cross-sections, one model, and the whole point of the
shared mapping was that there is only one.

So CHANNEL maps to `ArbitraryProfile` on both routes. `ChannelProfile` stays
refused outright -- Abaqus/Standard rejects the `section=CHANNEL` that CAE's own
exporter writes for one, identically for o=0 and o=0.05, and CAE cannot weigh a
member holding it -- and the reason recorded in
CAE_PROFILE_CLASSES_THE_SOLVER_REJECTS now names `ArbitraryProfile` as what to
use instead.

Measured against a UNP200x10, because agreeing with CAE is not the same as being
right:

  * CAE's own INP export from an ArbitraryProfile built on this table is the data
    block the INP writer writes, number for number. `abaqus datacheck`: ANALYSIS
    DATACHECK COMPLETE, 0 errors.
  * mass 101.406297 kg on a 4 m member against rho * Ax * L = 101.406300, a
    relative 3e-8 -- and not a coincidence: the midline area
    2(w - t_w/2) t_f + (h - t_f) t_w is algebraically identical to calc_channel's
    2 w t_f + (h - 2 t_f) t_w. A GeneralizedProfile in the same session reports
    mass=None.
  * centre of mass 0.01784244 from the web centreline against calc_channel's
    0.01775393: 0.4%.
  * Iy 1.919937e-05 and Iz 1.689222e-06 from the tip rotation of a cantilever
    under a pure end moment (B33, so no shear; a moment, so no torsion) against
    adapy's 1.9270167e-05 and 1.7060945e-06: 0.37% and 0.99% low. A
    GeneralizedProfile given adapy's own numbers in the same job reproduced them
    to 3e-6, which is what says the rig is sound.

The residual is two thin-walled idealisations differing, not an error in either:
calc_channel integrates full-width flanges and a web of h - 2 t_f, Abaqus
integrates each wall as a line of the given thickness and so omits each wall's own
t^3/12. Subtract those by hand from the midline model and it lands on Abaqus'
measured Iz to 1e-4. Under 1% either way, against an outline, stress recovery
points and a shear centre that the generalized section does not have at all.

Two things this turns up that are worth having in writing:

  ArbitraryProfile's `table` is one (x, y, t) row per point, the first row's
  thickness unused. The kernel does NOT check the row width -- a five-float first
  row was accepted in silence and read back with later rows padded with zeros,
  i.e. a different cross-section. So the table is derived from the same
  channel_midline_rows the keyword uses, and the emitted source is pinned verbatim.

  The offset keyword follows the section kind, and the two kinds are asymmetric.
  A BEFORE_ANALYSIS section refuses `beamSectionOffset` outright
  ("TypeError: keyword error on beamSectionOffset") and takes `centroid`. A
  DURING_ANALYSIS one takes `beamSectionOffset`, and treats `centroid` as the same
  stored member under another name -- probed one section per spelling with one
  exported INP each, both produced `*Beam Section Offset`. What it accepts and
  ignores is `shearCenter`. The writer already chose by profile class, so the
  channel's offset moved keyword by itself; a new licensed test builds adapy's own
  GeniE offset corpus in CAE and reads all three attributes back off the channel's
  section, because nothing short of the kernel can see which one holds the 90 mm.

The INP side is untouched: a re-capture of every section type's keyword and data
line, and of the section blocks of a written deck, is byte-identical before and
after this commit.

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

`read_sections.channel_from_arbitrary`'s docstring names
`write_sections.channel_arbitrary_lines` as the layout it is the inverse of. That
function now forwards to the shared mapping, so it has no caller -- and an uncalled
function is exactly where a second spelling of the same numbers survives a change
to the first.

So it is pinned rather than deleted: the block it returns must equal what
`line_section_props` puts under the keyword, and the layout must satisfy the
reader's own acceptance conditions -- six values on the first row beginning with 3,
two rows of three after it, the web centreline on x=0, both flange tips equal and
positive, the top flange above the bottom. Those five conditions are the ones
`channel_from_arbitrary` refuses a non-channel by, so the writer and the reader are
now checked against one statement of them instead of two.

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

adapy keeps a concept model's supports and loads in two disjoint stores, and only
one of them is what a solve is defined in. Part.concept_fem holds positions
(ConstraintConceptPoint, LoadConceptCase) and is what the GeniE reader fills;
FEM.bcs and the Load records inside FEM.steps hold mesh nodes and are what every
solver writer in the repo reads. This reads the second and refuses the first by
name rather than emitting a model without it. Measured on the frame the
cross-solver comparison is derived from, the two Bc records are on the *part's*
FEM while the step carrying the loads is on the *assembly's*, so the gather is
assembly-wide -- adapy's own get_all_bcs/get_all_steps idiom -- and writing the
part alone would have emitted the fixed bases and dropped the 10 kN.

Carried: a Bc as a DisplacementBC on an assembly-level vertex set, with every DOF
written and UNSET for the free ones, and a non-zero magnitude written as the
prescribed displacement it is -- which adapy's Sesam writer does not do (it
defines PRESCRIBED = 2 and its own comment says the magnitudes "are not carried
into BNDISPL yet"). A force Load as a ConcentratedForce named <load>_F and a
Moment named <load>_M, off the same Load.forces and under the same two names as
adapy's own INP writer's *Cload blocks. Every StepImplicitStatic as a StaticStep
chained through previous -- the Sesam writer emits only the first of a multi-step
model. Optionally the mesh (seedPart/setElementType/generateMesh at B31, B32 or
B33) and the job, whose ODB is read in the same run.

Refused, each by name and with the reason: every other load type (the Sesam
equivalent surfaces as TypeError several frames from the cause although
UnsupportedLoadType has been defined all along), a non-static step, an amplitude,
a local csys, a zero load, a velocity or connector BC, steps on two FEMs, a
support or load whose node is not at a vertex of the emitted geometry, and a
prescribed support with no step -- Abaqus refuses a non-zero support in the
initial step outright ("Non-zero boundary condition in initial step."), measured.

Two new guards run in the kernel. The solver's own CF and RF totals must be the
resultant adapy summed from its Load records, counting each region's vertices,
because Abaqus applies a ConcentratedForce's full component to every node of its
region; and the displacements are written to <stem>.cae_displacements.json,
joining the two ODB fields Abaqus splits them across -- U is (U1, U2, U3) and the
rotations are a separate UR. job.status is deliberately not consulted: it reads
None after waitForCompletion under cae noGUI=, so the .sta file's closing line is
used instead.

With this, verification/genie_vs_abaqus closes: Sestra 2.468646e-02 m against
Abaqus/B32 2.474589e-02 m top-corner sway, 54 of 54 components inside REL_TOL,
worst significant residual 4.9e-03. The residual is one measured number -- Abaqus'
PIPE section integrates the wall as a line, so its I is the thin-walled pi rm^3 t
and 0.276% below adapy's exact annulus -- and folding it in reproduces the
Timoshenko closed form to 4e-06 relative.

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

The cross-solver comparison passed at every one of its 54 components while its own hand
check failed for Abaqus by 0.077%, and the note left behind said the way to close that was
to widen the bracket by the measured section difference. It was not. Nothing was wrong with
the tolerance; the closed form was being fed the wrong number.

Abaqus' `section=PIPE` integrates the wall as a line, so its second moment is the
thin-walled `pi rm^3 t` and not the exact annulus `Section.properties.Iy` is -- 0.276% low
on this frame's OD200x10, which is wider than the 0.2% of slack at each end of the
admissible bracket. Both closed forms take `I` as given, so a solver that idealises the
section has to be checked against the section it idealises: the rule `sway_timoshenko`
already states for `shear_area` ("the shear area the *solver* uses, not a textbook shear
factor"), which turns out to apply to bending too. Feeding adapy's `I` to Abaqus compares
two quantities that were never the same one, and a correct translation fails by 0.077%.

So `portal_frame_predictions` takes an `inertia`, `compare.hand_check` passes it through and
the report prints which section it used -- a passing check must not hide what it was a check
against. The Abaqus half now lands at **1.981e-05** relative, where it was missing by
2.773e-03: 140x tighter, not looser. The bracket is untouched at 1.0% wide and is in fact
very slightly *narrower* for the thin-walled section, because `phi = 12 E I / (G As L^2)`
scales with `I`, so a 2% error is rejected exactly as before.

Which section Abaqus integrates stays a separate and sharper question, held by the licensed
`test_the_abaqus_pipe_sections_second_moment_is_the_thin_walled_one`. Its bound goes from
1e-03 to 1e-05: the measured 2.693525e-05 against the formula's 2.693523e-05 is agreement to
7.4e-07, so 1e-03 would have let a future Abaqus move its pipe integration by a third of the
whole effect this test exists to quantify without saying a word.

Also corrects two numbers in `abaqus_runner`'s docstring that were arithmetic slips, both of
which understated the result: `pi rm^3 t` is 2.693523e-05 and not 2.692935e-05, so the
formula reproduces the kernel to 7.4e-07 rather than the 2.2e-04 claimed.

Ten new checks in `selftest`, which needs no licence and now runs 32. Three mutations tried
-- the override ignored in `hand_check`, not passed through in `compare`, and the model
reporting its exact annulus -- and each is caught at the 2.773e-03 that is the real symptom.
Verified end to end against both solvers: Sestra 11.3-00 and Abaqus 2025 B32, 54 of 54
components inside 1e-02, both hand checks passing, exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
…pin the tolerance it never checked

Those 35 checks need no licence, no Sestra and no Abaqus, and nothing ran them: the package is
driven by hand from a command line. A comparator that has quietly stopped discriminating
still prints a table full of numbers, which is the whole reason the checks exist, so the suite
now runs them -- one test per group, so a failure names the part that broke.

Wiring them up turned up a gap in them. `check_agreement`'s two perturbations are written as
multiples of `compare.REL_TOL` -- `_scaled(1.0 + 0.5 * REL_TOL)` and `1.0 + 2.0 * REL_TOL` --
so they scale with the tolerance and are blind to the one thing they look like they are
checking. Mutating `REL_TOL` from 1e-2 to 1e-1 left every check in the file passing, while the
real comparison would then call a 5% disagreement between two solvers a match. Found by
mutation; it is not visible by reading, which is the argument for running mutations at all.

So three checks stated in absolute terms: a flat 2% disagreement must fail, a flat 0.1% must
pass, and `REL_TOL` must sit above the measured worst residual (4.945e-03, `GIRDER_3QTR.r2`,
a shear-rigid/shear-flexible difference where cancellation amplifies it elevenfold) and below
2e-2, where it would start admitting the 7.4e-03 B31 element error it exists to catch. Both
directions of the mutation are now caught.

`MINIMUM_CHECKS` is a floor rather than an equality so adding a check does not mean editing a
line, but net deletion -- a guard removed leaving a suite that still passes -- is caught.

Mutations verified against the new wrapper: the section-idealisation fix reverted, the
too-few-probes guard disabled, `ABS_FLOOR` raised to swallow real differences, and `REL_TOL`
moved in either direction. All caught. Suite: 4616 passed, 0 failed.

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

A concept model's plates now reach Abaqus/CAE as editable geometry: one `.sat` per part
beside the emitted script, imported with `mdb.openAcis` + `PartFromGeometryFile`, each face
located by a point and given a `HomogeneousShellSection` with the plate's own thickness and
material. Beams are unchanged -- measured, a `WirePolyLine(mergeType=IMPRINT)` drawn over an
edge the body already carries is idempotent (2 faces / 7 edges / 6 vertices either way).

Why the SAT and not an outline rebuilt in CAE: adapy's own SAT writer produces the body
Genie consumes, with arcs analytic, curved plates as NURBS patches and the plates already
split along the beam axes lying on them. CAE reads it exactly -- a 3 x 2 m plate split by a
stiffener imports as 2 faces of 3.0 each against adapy's 6.0, and the stiffener line is ONE
edge bounding both faces. Rebuilding the outline would compound the Genie reader's
documented losses instead.

Why a point and not a name: `PartFromGeometryFile` discards the ACIS attributes. After the
import `part.sets.keys()` is `[]`, so the `FACE00000001` names adapy writes reach Abaqus as
nothing. Each face's point is walked out of the authored body's own plane faces (or found
parametrically on a spline face through the CAD backend) and chosen to maximise its
clearance from the boundary -- not as a centroid, because the area centroid of a C-shaped
outline falls in its notch and `findAt` there returns 0 faces with only a warning, and a
point on an edge two faces share returns one of the two arbitrarily.

A beam whose axis lies ON a plate is refused, and the reason is measured rather than
cautious: in Abaqus/CAE 2025 an edge shared with a face takes a beam section, reads it back,
exports a `*Beam Section` keyword -- and produces no elements when the part is meshed
(`{'S4R': 96}` with no B31; the same beam moved clear of the plate gives
`{'S4R': 96, 'B31': 12}`). Five spellings were tried and all five give the same. Neither
half of such a pair can be dropped without writing the wrong structure, so the default is to
refuse the model by name and `plates=False` writes it as this writer did before.

Guards extended rather than relaxed:
* every FACE carries exactly one shell section, and every edge either carries exactly one
  section or bounds a face. The third clause is what keeps "every edge is sectioned" from
  degenerating into "some are" once a plate contributes boundary edges;
* per-plate area against adapy's own, absolute and scaled by the expected value because a
  spline face Abaqus considers invalid reports `getSize() == 0.0` rather than raising;
* per-face normal against the plate's declared normal -- measured to survive the import
  unflipped, so the script says so on every run;
* the topology guard counts faces exactly and beam edges as "edges bounding no face"; a
  plate part's edge and vertex totals are not adapy's to state, because a member landing on
  a plate boundary splits it (4 edges / 4 vertices became 7 / 7).

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

The previous commit refused such a member, on the strength of a measurement that was real but
whose conclusion was wrong. What was measured is that an **ordinary** edge shared with a shell
face produces no beam elements:

    shell sections on both faces, a BeamSection on the shared edge, B31 on it
    generateMesh()                      {"S4R": 96}          -- no B31 at all
    nodes on the stiffener line         13
    nodes shared by a shell and a beam  0
    getUnmeshedRegions()                faces 2, edges 0     -- CAE saw no edge to mesh
    CAE's exported INP                  *Element, type=S4R
                                        *Beam Section, elset=..., section=I

and that five spellings of it all give the same. What was NOT tried is `Part.Stringer`, which is
Abaqus/CAE's own concept for a beam reinforcing a shell along an edge. On the identical part, with
the identical section and element type, the only difference being the Stringer feature:

    generateMesh()                      {"S4R": 96, "B31": 12}
    nodes shared by a shell and a beam  13 of 13
    the node at (1.5, 1.0, 0.0)         {"S4R": 4, "B31": 2}
    CAE's exported INP                  *Element, type=B31 as well as type=S4R

A plate BOUNDARY edge behaves the same, so the edge-stiffener half of the refusal goes too. On a
4 m strip the stiffened case added 80 beam elements and **not one node** -- 891 either way -- so
the beams reuse the shell's nodes rather than sitting beside them, which is exactly what
`mergeType=SEPARATE` failed to do.

And it carries load. The same strip, simply supported, in cylindrical bending, under 1000 Pa, with
a 10 x 45 mm bar as a stringer along its centreline. The stringer's axis IS the plate's
mid-surface, so the two stiffnesses add with no eccentricity term and the estimate is exact:

    EI_plate = D b                            9615.384615384617 N m2
    EI_bar   = E a b^3 / 12                  15946.874999999998 N m2
    w_bare    closed form 0.1733333333333333   Abaqus 0.17329297959804535   2.3e-04
    w_stiff   closed form 0.06520028713203371  Abaqus 0.06520956754684448   1.4e-04
    ratio     0.37615550268480996              Abaqus 0.3762966491666233    3.8e-04

So adapy's own GeniE fixture -- 7 plates, each with a stiffener lying on it -- now builds
completely: 7 faces all sectioned, 7 stringers all sectioned and oriented, `{"B31": 14,
"S4R": 28}`, every plate's area and normal exact.

Two clauses replace the refusal, and both are asserted in the kernel rather than trusted: a member
the writer drew as a WIRE must have edges bounding no face (an absorbed wire is a member in the
geometry and absent from the analysis), and a member built as a STRINGER must have edges that do.

A curved member lying on a plate is still refused, because where along a spline CAE puts an
imprinted vertex is the spline's own parameterisation's business and the sub-edge count this
writer asserts for every member would not be knowable. Measured: today's SAT writer cannot
produce that case anyway -- it imprints each beam's chord, not its curve -- so the guard is a
backstop and its test injects the condition rather than pretending to reach it.

Also: a real curved plate is translated. The synthetic patch that Abaqus called invalid geometry
carried no pcurve on any edge; a Genie `curved_shell` does, and that one imports, measures
(6.91520453146419 against adapy's 6.915204361623685) and meshes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
…ch way it pushes

The CAE writer's `pressure` refusal said "this writer translates beams only -- the emitted model
has no face for it to act on", which stopped being true when plates arrived. A refusal message
that has become untrue is worse than no message, so the type is carried instead.

One `ada.fem.Load` of type `pressure` becomes an assembly-level `Surface` over the whole of one
plate plus a `Pressure` on it. Measured in the kernel:

    assembly.Surface(name='q_surf', side1Faces=<the plate's faces>)   accepted
    model.Pressure(name='q', createStepName='static',
                   region=assembly.surfaces['q_surf'], magnitude=1000.0)
    CAE's exported INP                *Dsload
                                      q_surf, P, 1000.

Which plate a pressure acts on is resolved through adapy's own linkage rather than geometrically:
`Elem.refs` holds the object a shell element was meshed from -- verified, a 48-element mesh of one
plate reports that `Plate` in every element's `refs`. Three things are refused, each because a CAE
`Pressure` cannot express it: a node set (a pressure has no meaning at a point), elements from more
than one plate (one Surface names one plate's faces), and a set covering only PART of a plate -- a
CAE surface is made of whole faces, so that would become more load than the model describes,
applied where it does not.

`side1Faces` fixes the sign, and it is stated in the emitted script rather than left to be
inferred: Abaqus takes a positive pressure as acting INTO the surface, against side1's outward
normal, which is the plate's declared normal (measured to survive the ACIS import unflipped). So a
positive magnitude on a plate whose normal is +z pushes it in -z.

Two API facts the shape follows from, both measured: a `Surface` lives in
`rootAssembly.surfaces`, a different repository from `rootAssembly.sets`, so guard 7 gained a
namespace and a pressure's region is not in `PLANNED_ANALYSIS['regions']`; and a `Pressure` object
has **no** attribute carrying its magnitude back, so the only place the number can be read is the
deck -- which is the place that matters.

Worth recording: `ada.fem.loads.LoadTypes.all` does not list `pressure` and the `Load` constructor
sets the type without going through the validating setter, so nothing in adapy builds one today,
and `LoadPressure` takes a `Surface` while leaving `fem_set` unset. The element-set spelling is the
one carried, because it is the one that resolves to a plate; the `Surface` spelling still refuses,
now for a reason that is true.

Sidecar schema 4 -> 5: `plates`, `pressure_faces`, and four per-part guard keys were added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
…in their own frames

The CAE writer refused every offset on a curved member. Measured on Abaqus 2025
(D:\temp\cae_probe\lift\arc), that was too broad by exactly one case, and it is the
ordinary one: a quarter arc R = 4 in the XY plane, 16 x B32, PIPE OD200x10, n1 the
writer's own chord yvec, fixed at one end and carrying Fx = Fz = My = 1e4 together at
the other, was run with beamSectionOffset=(0, 0.4) on the node line and again with the
arc drawn at z = +0.4 and tied back to a reference point at each end by *MPC BEAM -- the
rigid link adapy's INP writer builds for an eccentricity. All six components agreed to
every printed digit (u = 2.437131479e-02, 6.210658699e-02, 1.610077024e-01;
ur = 1.368860062e-02, 4.019251838e-02, -1.614604890e-02). Abaqus applies the pair in the
local (n1, n2) frame element by element, so a curve is no obstacle in itself.

So the test is made in the frame, not on the global vectors: each end's own e is
projected onto that end's own (n1_proj, n2) -- n1 projected perpendicular to the tangent
there, which is what N1_COSINES makes Abaqus do -- and the pair is emitted when the two
ends agree within ECCENTRICITY_TOL, refused with both tuples quoted when they do not.
Comparing e1 against e2 is neither necessary nor sufficient and both halves are ordinary
on an arc: adapy's Genie reader resolves an eccentricity per end (segs[0]/segs[-1]), so
an offset that follows the frame has e1 != e2 and is exactly expressible, while a
constant radial one has e1 == e2 and reads -0.2 along n1_proj at one end of a quarter arc
and -0.0022 at the other. The axial component is checked per end afterwards, because a
pair has no slot for it and both ends of a tangential offset project to (0, 0).

The end frames come from the sampled end chords rather than an analytic tangent, and that
error is spent on refusing: the measured out-of-plane case is exact either way, while an
offset that follows the curve is refused for the 2.2e-03 of apparent axial content the
chord's own tilt puts on a 0.2 offset. The alternative was a tolerance wide enough to
drop a real axial component of that size in silence.

Mutations run: comparing the two ends' global vectors instead of their own-frame
projections (4 tests); n2 = n1_proj x t instead of t x n1_proj (2 tests); the curved
offset returned as None (the licensed run); the emitted pair negated (the licensed run,
on the displacements: 0.0606 against 0.0244).

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

BeamTapered stays refused, and the reason it gives was half the story: it cited only the
segfault, which invites the obvious "so write it BEFORE_ANALYSIS instead". Both routes
were measured on Abaqus 2025 (D:\temp\cae_probe\lift\taper, ...\taper_phys), one CAE
process per variant with a stage sidecar either side of job.writeInput():

* integration=DURING_ANALYSIS + beamShape=TAPERED segfaults the INP writer for PIPE, I,
  RECT, CIRC and BOX alike and for both B31 and B32, while the constant-section control
  writes; job.submit() dies the same way because it writes the deck first;
* integration=BEFORE_ANALYSIS writes a plausible '*Beam General Section, ..., Taper,
  section=PIPE' with both end profiles, and it solves -- and its answer does not depend on
  which end is which. A 4 m cantilever under a tip Fz = 1e4 gives u3 = 9.122384e-02 /
  8.756731e-02 / 8.635153e-02 at 1, 2 and 40 elements, identical with the taper reversed,
  where a real taper differs by about 3x between the two directions (6.394e-02 against
  1.966e-01). It converges on a constant effective inertia of about 1.18e-05 m4 -- neither
  end (2.69e-05 / 2.86e-06), nor their mean, nor the mid-radius section (1.08e-05).

So one route cannot write the model and the other writes a different one that meshes,
solves and is wrong. Faithful tapering needs per-element sections, which a concept model
with editable geometry does not have until it is meshed, and the message now says so.

Mutation run: the reason truncated back to the segfault alone -- caught by
test_the_tapered_refusal_cites_both_measurements_rather_than_a_preference.

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

curves.py refused a sweep path with more than one leg, which in practice refused every
BeamSweep: adapy's CurveOpen2d decomposes the three-point filleted corner
[(0,0), (2,0,r=0.5), (2,2)] into FOUR segments3d -- a closer from the last point back to
the first, then line, arc, line. The refusal was about the writer drawing one wire per
member and locating it with one bounding volume, not about anything Abaqus cannot do.

Probed first (Abaqus 2025, D:\temp\cae_probe\lift\sweep, recorded in the facts file):

* three wires drawn line-arc-line through shared end points with mergeType=IMPRINT build
  edges=3 vertices=4 -- the junction vertex MERGES, exactly as a real joint's does, where
  unmerged would be 6 -- and the mesh puts ONE node at each junction (15 elements, 16
  nodes at a 0.25 seed, 6/3/6 per leg). The L of two straight legs gives edges=2
  vertices=3, 16 elements, 17 nodes, 1 node at the corner;
* legA + legB + legC on the edge sequences is the spelling that makes one Set: a tuple of
  Edge objects fails with 'AbaqusException: Feature creation failed.', and SetByBoolean
  works but needs a throwaway set per leg -- and a set name is the key the result sidecar
  reports a member under;
* getMassProperties() over the combined region weighs both legs: volume
  0.023876103455719066 / mass 187.42741212739466 against 2*pi*rm*t*4.0 = 0.023876104167
  and 187.42741771, 3.0e-08 relative, which is the profile's numerical integration.
  It takes a regionToolset.Region; a Set is 'TypeError: regions; found Set, expecting
  Region'.

So: sample_member_legs() returns one MemberLeg per wire -- the closer identified as the
one leg spanning the path's own two ends and dropped, the rest walked into a chain from
the first point and turned round where they point backwards, each arc leg sampled by
exactly the code a whole curved member is sampled by (refine_and_measure, extracted for
it). The writer plans one _LegPlan per leg with its own cylinder or its own spline, draws
one wire each, locates each leg its own way, and concatenates the sequences into the
member's ONE set, section assignment and orientation. n1 is checked along the whole
concatenated path, so a leg running along n1 is refused as it is for a single curve, and
the topology guard states one segment per wire and adds the legs' counts back together
per member (merge_leg_counts), so a brace landing on one leg of an L reads 3 sub-edges
and not 2. A path that does not chain, or whose closer cannot be told apart, is refused
with the point it parts at. A single straight leg is no longer refused either: it is the
one wire it describes.

A straight member and a single-curve member are untouched: same plan fields, same emitted
text, same golden file.

Mutations run: only the first leg drawn (2 AST tests, and both licensed tests -- the
in-kernel guard failed the build with "leg 2 of it contains no edge at all"); the legs'
counts never added back together (3 tests); the closer left in the path (7 tests).

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

EXPECTED_BBOX is compared against the part's own **vertices** in the kernel, and a chained
member's junctions are vertices -- measured: three wires sharing their end points build
edges=3 vertices=4. It was stated from members' end nodes alone, so a swept path that
reaches outside the box its two ends span would have failed a perfectly good build: the
detour [(0,0), (0,2), (4,2), (4,0)] has both ends on y=0 and its middle legs on y=2, and
the guard would have reported the built geometry as 2 m out of place.

Sample points on a curve stay out of the box on purpose: they are interior to an edge, CAE
holds no vertex at them, and putting them in would state a box the kernel cannot match.

Also noted where the legs are planned: a support or load AT a junction is refused rather
than attached, because analysis.vertex_index states the vertices from members' ends and
imprinted splits and a junction is neither. CAE does hold a vertex there, so that is a
false refusal rather than a wrong model; it belongs to the analysis module.

Mutation run: the legs' own ends left out of the box again -- caught by
test_the_stated_bounding_box_covers_a_swept_members_legs_and_not_only_its_ends.

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

_spline_lines took the beam's name and never used it -- the comment it writes is built from
the `what` argument, which says whether it is describing a whole member or one leg of one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
…es work gave it

Rebased onto feat/abaqus-cae-plates, whose topology guard and EXPECTED_TOPOLOGY carry the
plates keys -- faces, stringers, wire_edges, the face section counts, edges_bounding_no_face.
The two sweep tests compared whole dicts written before those keys existed; the counts they are
about (edges 2, vertices 3, one sub-edge per leg) were already right. They now state the full
plates-era shape for a beams-only part: zero faces expected and built, no stringers, and both
wire edges bounding no face.

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

A curved plate is located in CAE by a point adapy finds strictly inside its spline face, and
that point comes from the pythonocc kernel (`OccBackend.interior_point_on_face`). CI's default
test env has no pythonocc, so the three tests that translate a real curved plate failed there
with `No module named 'OCC'` -- on all three platforms and in the conda build -- while the writer
did the right thing: refused the plate by name. The repo's `pyocc` marker deselects such tests
where the kernel is absent and runs them on the compat leg that carries it, and it is a marker
declared by hand here because the tests reach the kernel through the writer rather than through
the `occ_backend` fixture the conftest derives the marker from.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
(cherry picked from commit 14ee8d9)
…s refused

It has been a Stringer since b3a4d7a; the docstring still described the refusal that commit
replaced, with the measurement that motivated the refusal but not the one that lifted it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
(cherry picked from commit 7565a34)
@github-actions

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.92.0"

@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

🚀 Profiling Results (Top 20 most expensive calls)

Function Calls Duration (s)
<built-in method builtins.exec> (~:0) 1373 18.2729
<module> (<string>:1) 1 18.2729
test_bench_batch_tessellate (tests/profiling/test_cad_backend_bench.py:63) 1 5.4846
test_build_big_ifc_pipe (tests/profiling/test_ifc_creation.py:48) 1 5.4446
run (tests/profiling/test_cad_backend_bench.py:73) 6 5.3306
batch_tessellate (src/ada/visit/tessellate.py:1102) 1446 5.3295
__truediv__ (src/ada/api/spatial/part.py:2161) 8 5.1759
tessellate_geom (src/ada/visit/tessellate.py:830) 1440 4.8199
tessellate_occ_geom (src/ada/visit/tessellate.py:684) 1440 4.3119
tessellate_shape (src/ada/visit/tessellate.py:554) 1440 4.2978
to_ifc (src/ada/api/spatial/assembly.py:325) 5 3.8444
add_object (src/ada/api/spatial/part.py:309) 1201 3.7924
add_pipe (src/ada/api/spatial/part.py:164) 200 3.7763
sync (src/ada/cadit/ifc/store.py:200) 5 3.5934
sync_added_physical_objects (src/ada/cadit/ifc/write/write_ifc.py:92) 5 3.5459
add (src/ada/cadit/ifc/write/write_ifc.py:582) 2200 3.4771
tessellate (src/ada/cad/__init__.py:1193) 1440 3.4437
add_section (src/ada/api/spatial/part.py:294) 801 3.2851
add (src/ada/api/containers/sections.py:139) 805 3.2840
equal_props (src/ada/sections/concept.py:100) 241399 3.0212

oleandor and others added 4 commits September 27, 2026 08:08
…aces

adapy gives a Bc through a FemSet of mesh nodes and CAE carries a support on
geometry, so analysis.py resolved a region to a geometric VERTEX and refused
everything else. That refused the ordinary case: a simply supported plate edge.
The verification workstream had to append its own driver to the emitted script
to get three supports onto a strip (verification/genie_vs_abaqus/
plate_abaqus_runner.support_driver).

classify_region now classifies a set's nodes against the geometry this writer is
about to build, in order, refusing anything ambiguous by name:

  1. every node at a vertex           -> a vertex Set (unchanged)
  2. every node on whole edges        -> an edge Set, found by getByBoundingBox
  3. the set is one plate's mesh      -> a face Set over that plate's faces
  4. anything else                    -> refused, naming the node, the nearest
                                         vertex, and the edge it half-covers

"Whole edges" is two clauses and both are measured. A DisplacementBC on an edge
region restrains every node CAE puts on the edge after meshing -- 10 reacting
nodes at a 0.125 seed became 18 at 0.0625, with nothing between the two solves
but a re-seed and a re-mesh -- so the set must hold every node the model has on
the edge and reach both its ends. A set covering half an edge would otherwise
become a support over all of it.

The edges adapy can state come from two places, and the first is the reason this
is possible at all: the ACIS body written for a part's plates already enumerates
every face-bounding edge with its endpoints, ALREADY SPLIT BY THE IMPRINT. On a
4 x 0.5 m strip with a bar along its centreline the body carries 7 such edges and
CAE imports exactly 7, the x = 0 and x = L boundaries each cut into two 0.25 m
sub-edges by the bar's ends. The second is the straight wire members, split at
the landings expected_topology already computes.

Located by bounding box, one box per COLLINEAR RUN, and never by findAt -- both
halves measured on that split boundary:

    findAt(((0.0, 0.125, 0.0),))  -> 1 edge, length 0.25   half the support
    findAt(((0.0, 0.25,  0.0),))  -> 1 edge, length 0.25   at the split point,
                                                           one of the two
    box x[-1e-7, 1e-7] y[-1e-7, 0.5+1e-7]   -> 2 edges, total 0.5
    ONE box over the two long edges         -> 7 edges, total 13.0 (the body)

So the emitted script checks each box's edge count and the region's total length
against adapy's own, and fails the build on disagreement. A face region's total
area is checked against adapy's area for the plate the same way.

A force load stays vertex-only. Abaqus applies a ConcentratedForce's full
component to every node of its region, so on an edge the applied total would be
the magnitude times a node count that moves with the seed (5 at 0.125, 9 at
0.0625). The faithful object is a LineLoad per unit length and adapy's Load has
no type for one -- LoadTypes lists gravity, acc, acc_rot, force, force_set, mass
and pressure, with no per-unit-length magnitude anywhere in the record -- so it
is refused by name rather than written as a mesh-dependent total.

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

Licence-free, on the strip the cross-solver plate comparison is closed with,
rebuilt in the test rather than imported from verification/ so that a unit test
does not acquire a licence-shaped dependency.

test_cae_regions.py pins all four outcomes of the classifier and the six
refusals:

  a plate corner            -> vertex, 1 point, no box
  x = 0, bare               -> edge,  1 edge,  0.5, one box
  x = 0, stiffened          -> edge,  2 edges, 0.5, one box (collinear run)
  both long edges           -> edge,  2 edges, 8.0, TWO boxes
  the plate's whole mesh    -> face,  area 2.0, one point per face
  half an edge              -> refused: "reach only 0.25 to 0.5 of its 0.5"
  every other node of one   -> refused: "the set holds 3 of the 5 node(s)"
  one interior node         -> refused: "lies on no edge", with the nearest
  a corner + an interior    -> refused: "1 of its 2 node(s) are at no vertex"
  a force along an edge     -> refused: "is not a line load"
  a force over a plate      -> refused: a distributed load over a plate is a
                               Pressure

The every-other-node case is the one a length check alone would have passed --
the ends are covered and only the middle is missing -- which is why the rule is
"every node the model has on that edge", asked of the owning FEM, and not a
tolerance on the span.

And the boxes, edge counts and lengths are asserted IDENTICAL at seeds 0.125,
0.0625 and 0.03125 while the node counts go 5/9/17 and 66/130/258. That is the
property the whole change exists for: the region is geometry, so it does not
move with the mesh.

The AST pass now reads all three region helpers, and _check_edge_region adds the
one thing only a text reader can do -- check that the numbers the script asks the
kernel to confirm agree with EACH OTHER. Five new mutations, each rejected by
check_analysis_references_resolve alone and by the whole pass:

  box counts not summing to the region's edge count      caught
  box lengths not summing to its total length            caught
  a box with xMin == xMax (encloses nothing)             caught
  a region checked against a total length of 0.0         caught
  a face region checked against an area of 0.0           caught

The last two are the mutations that matter most: a guard comparing against zero
passes on any geometry at all, which is a guard that has stopped guarding.

tests/core: 4763 passed, 22 skipped, 16 xfailed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
…d see a pressure's resultant

The licensed acceptance the region work needed, and the one writer change it
turned out to require in order to run at all.

THE ACCEPTANCE. The strip is emitted entirely by the writer -- its three supports
included, which it refused until this branch -- then meshed, submitted and read
back. The appended driver creates no region, no support, no load and no step; all
it does is report what the kernel held and which nodes reacted.

    u3 at mid-span, from the writer's own displacement sidecar
        -0.17306548357009888
    what verification/genie_vs_abaqus/plate_abaqus_runner.support_driver got for
    the same model at the same seed, with regions it built BY HAND because the
    writer refused them
        -1.730654835701e-01
    and a third, independent hand-built CAE probe
        -0.173065483570099

Three routes, the same number. Which is the point: the writer's regions are the
same regions. A support that came out half the length of its edge -- which is
what findAt gives on a stiffener-split boundary -- would move this by about 10%.

    region_edges read back from the kernel   CYL [2, 8.0]  SS_X0 [1, 0.5]
                                             SS_X1 [1, 0.5]
    region_kinds                             all three 'edge'
    u3 at all five nodes of each supported edge   0.0 to 1e-34
    nodes with a reaction                    10, all ten on the two short edges
    closed form 5 q L^4 / (384 D)            0.1733333, 1.5e-03 above (S4R at
                                             this coarse seed, second order)

(The brief's 0.1731979102 is the Sestra FQUS column of plate_hand_check's table
at this seed, not the Abaqus one.)

THE WRITER CHANGE. The equilibrium guard compared the ODB's CF total against the
load adapy summed from its Load records, then asked that CF + RF cancel. A
pressure appears in NEITHER: it is a *Dsload on a surface, and its only trace in
the results is the reactions balancing it. So submit=True on a model whose load is
a pressure failed the build as "not in equilibrium" by the whole of its own load
-- which is every plate model, now that its supports can be written.

adapy already holds what is needed: the magnitude, its own area for the plate and
that plate's declared normal. side1Faces fixes the sign, so the resultant is
-magnitude * area * normal:

    APPLIED_PRESSURE from adapy   (0.0, 0.0, -2000.0)  = -q L b
    ODB reaction total            (-1.1e-13, 4.6e-12, 2000.0)
    residual                      4.6e-12 against a tolerance of 0.2

That makes the check real rather than a bookkeeping identity: a surface holding
the wrong faces, or a plate CAE built at a different size, moves it. The
concentrated-force half is untouched -- CF is still compared against APPLIED_FORCE
component for component.

Also measured: Abaqus writes NO CF field at all for a model whose only load is a
*Dsload. That is not a defect, so a missing CF is read as zero when adapy planned
no concentrated force, and is still fatal when it planned one.

Licensed acceptance: 35 passed. tests/core: 4765 passed, 22 skipped, 16 xfailed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
…use the completeness test hides

Two gaps the mutation runs found, and both are the same kind of gap: a clause with
no test that distinguishes it.

1. The licensed acceptance solved only the BARE strip, where findAt and a bounding
   box agree -- both find one edge. The split boundary is the whole reason the box
   exists, so the stiffened strip is now solved too:

       region_edges   SS_X0 [2, 0.5]   SS_X1 [2, 0.5]   CYL [2, 8.0]
       mesh           {'S4R': 128, 'B31': 32}
       u3 at mid-span -0.0651114955544472, the hand-built probe's number, and
                      2.66x stiffer than the bare strip
       u3 at all five nodes of each supported edge, both ends: 0.0

   With findAt at the box centre instead of getByBoundingBox the build now fails
   in the kernel: "the box x[-1e-07, 1e-07] y[-1e-07, 0.5000001] returned 1 edge(s)
   and adapy predicted 2, of total length 0.5". Against the bare strip alone that
   mutation passed.

2. The edge classifier's coverage clause -- the set's extreme nodes reaching the
   edge's two ends -- was subsumed by the completeness clause on every case the
   suite had. On a mesh that conforms to the emitted geometry it always is: a set
   holding every node the FEM has on an edge reaches both ends by construction.
   That premise is exactly what this writer does not assume anywhere else, so the
   clause is now tested where it is the only one that fires: an EdgeSegment twice
   as long as the mesh line the set covers. Completeness passes, coverage refuses,
   and the message says "reach only 0 to 0.5 of its 1 length" -- because carrying
   it would have supported a metre of structure where the model named half of one.

Mutations run, each with a green baseline asserted first and a run that collected
no tests treated as a failure:

  the coverage clause dropped                       caught (licence-free)
  the completeness clause dropped                   caught (licence-free)
  findAt instead of the bounding box                caught in the kernel
  a predicted total length 10% wrong                caught in the kernel
  the same, with the in-kernel length check off     PASSES -- which is what says
                                                    the kernel check is what
                                                    caught the one above, and not
                                                    the displacement assertion

Licensed acceptance: 36 passed. tests/core: 4767 passed, 22 skipped, 16 xfailed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
oleandor and others added 6 commits September 27, 2026 08:22
…ence bracket

The portal frame compares two beam formulations node for node at one mesh, because
neither BEAS nor B32 has meaningful discretisation error left. Shells are not like
that: Sestra's FQUS and Abaqus' S4R are both 4-node bilinear elements and both
converge with mesh, so at any single mesh they differ by *discretisation* rather than
by formulation -- measured on this strip, 7.65e-04 of the answer at 32 elements per
span against 1.72e-05 between the converged values. A single-mesh plate comparison
would therefore be measuring the elements, not the translation.

So the plate case is a mesh-convergence bracket. plate_model builds a 4.0 x 0.5 m
strip, 10 mm, S355, at three seeds a factor of two apart (0.125 / 0.0625 / 0.03125 ->
165 / 585 / 2193 nodes, the same structured grid on both sides, measured), in two
variants: bare, and with a 10 x 45 mm flat bar along the centreline.

Every dimension buys a term in the closed form:

* the long edges are held u2 = 0, ur1 = 0, so the strip is in CYLINDRICAL bending and
  D = E t^3 / (12 (1 - nu^2)) is the right stiffness. A strip with free long edges
  curves anticlastically and sits somewhere between that and E I with no 1 - nu^2 --
  9% away, and which regime it is in is set by b^2 / (R t), about 2.9 here, squarely
  in the ambiguous middle. Constraining the edges removes the ambiguity rather than
  budgeting for it, and plate_compare.assert_cylindrical checks it rather than
  assuming it;
* the bar's centroid is on the plate's mid-surface in both decks, so plate and bar
  bend about the same axis and their stiffnesses simply add: the parallel-spring
  estimate is exact, with no E A e^2 term to get wrong;
* the bar's 45 mm dimension stands out of the plate, which both writers reach from
  the model on their own -- adapy's Iy = 7.59375e-08 = a b^3 / 12 and the CAE writer's
  n1 = (0, 1, 0). Laid flat it would be 3.75e-09, 20x smaller.

plate_hand_check carries three closed forms and the convergence arithmetic:

    D                 = 19230.769230769 N m
    5 q L^4 / (384 D) = 0.173333333333 m     bare
    q L^3 / (24 D)    = 0.138666666667 rad   support rotation -- what says the support
                                             is SIMPLE; fixing that rotation is 5x
    EI_plate / sum EI = 0.376155502685       the bar makes the strip 2.658x stiffer

observed_order READS the order off three values rather than assuming 2, and richardson
refuses a sequence outside ORDER_BAND rather than extrapolating it -- a locking element
or a mesh that was not actually refined comes out of that function as a number and is
rejected by name. Measured on twelve solves, the order is 2.0000 (Sestra bare),
1.9999 (Abaqus bare), 2.1905 and 2.0305 stiffened.

Two tolerances, because there are two questions. BARE_REL_TOL is 1e-04, 5.8x the worse
of the two extrapolants' residuals (8.02e-09 Sestra, 1.720e-05 Abaqus).
STIFFENED_REL_TOL is 1e-03, 5.8x a *known* modelling gap: the parallel-spring form
assumes the deflection is uniform across the width and it is not, because the bar is a
line of stiffness at the centreline while the long edges are held -- measured 8.0e-05
across the width in both solvers, leaving both extrapolants ~1.65e-04 above the closed
form. That is physics, stated, not tolerance absorbed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
…er gaps that shape it

plate_sestra_runner and plate_abaqus_runner drive the same plate_model.build_strip at
three seeds each. Both end in displacements.sample_fea_result, so probe correspondence
is solved once for both cases and both solvers; both read their solver's own reaction
total, which is the one number that says the whole load arrived and the only one that
does not come from adapy's own arithmetic.

Two gaps in src decide what each side can be handed. Both are reported here rather
than worked around silently, and neither is fixed -- the Sesam and CAE writers belong
to other workstreams.

**1. adapy's Sesam writer has no distributed-load record at all.** write_loads.load_str
reports `[OMITTED] a "pressure" load is not written by the Sesam writer` and returns
""; there is no BEUSLO and no BELOAD anywhere in ada/fem/formats/sesam/write. The
dangerous part is what happens next: the deck is written, Sestra solves it, reports
"Execution completed successfully" and returns an UNLOADED structure. Two such runs
agree with each other perfectly. compare.assert_has_signal is the only thing standing
between that and a green report.

So the Sestra side is given plate_model.consistent_nodal_loads instead, and the
substitution is exact rather than approximate: for a 4-node bilinear quad
integral(N_i) dA = A / 4, so q A / 4 at each of an element's four nodes IS the vector
Abaqus assembles from a pressure. Both solvers mesh this strip with 4-node quads on the
identical grid. Measured: three Load records become 165 / 585 / 2193 BNLOAD records
summing to -2000.000000 N against q L b = -2000, and each solver's own reaction total
comes back 2000.0 in z (1999.99996 on the stiffened strip, 2.3e-08).

Also measured, and it corrects sestra_runner's gap list: gap 2 no longer holds.
load_force now loops every member of the set, so three Load objects carry 2193 nodal
forces. Shells reach Sestra as FQUS (Sesam element type 24, every GELMNT1 record), and
the stiffener's beam elements share every node of the shell mesh -- 33 / 65 / 129 on
the line, all shared, and the mesh gains no nodes at all for the bar.

**2. the CAE writer carries a support only on a geometric vertex.**
analysis._resolve_region resolves a FemSet's node positions against the vertices of the
emitted geometry, and a plate edge's interior nodes are not vertices, so this model's
three supports are refused by name. Reproduced on demand and without a licence by
plate_abaqus_runner.reproduce_edge_support_refusal:

    CaeWriteError: boundary condition 'CYL' (on Strip's FEM) acts at (0.125, 0.0, 0.0)
    through the set 'CYL', and the emitted geometry has no vertex there -- the nearest
    vertex is at (0.0, 0.0, 0.0) in part instance 'Strip-1', 0.125 length units away.

That is a capability gap and not a defect: nothing is written wrongly, and the refusal
carries its own measurement. So the three supports are emitted by support_driver,
appended to the writer's own script -- generated from the model's own Bc records through
analysis.BC_KEYWORDS, so "simply supported" is written once, in EDGE_SUPPORTS, and on
GEOMETRY edges found by getByBoundingBox rather than mesh nodes, so one statement of it
serves all three densities. getByBoundingBox and not findAt because a supported edge of
the stiffened strip is TWO edges -- the bar splits the body along its axis -- and findAt
at one point would have left half of it unsupported.

Everything else on the Abaqus side is translated: the plate as the ACIS body adapy's own
SAT writer produces, a HomogeneousShellSection at MIDDLE_SURFACE (which is what puts the
bar's centroid on the plate's reference surface), the bar as a Part.Stringer with
n1 = (0, 1, 0), S4R and B31, the StaticStep, and the pressure as a Surface + Pressure.
Measured: {'S4R': 128, 'B31': 32} at the coarsest seed with 165 nodes either way, and
2048 + 128 on 2193 at the finest -- the same counts as the Sesam deck's FQUS and BEAS,
element for element.

The driver writes the writer's own ada.cae_displacements/1 schema, so
abaqus_runner.read_displacements_sidecar reads it unchanged -- including the join across
U and UR, which Abaqus stores as two separate fields.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
… tolerance the study set

compare is reused unchanged for the verdict -- the per-point per-component table, the
probe-set rule and the all-zero rule all apply as written, and the portal frame is
untouched. plate_compare adds only what shells need: a convergence sequence per solver
per variant, the extrapolation that turns three tables into one, and four guards of its
own. run_comparison gains --case plate (frame is the default) and --reuse, because six
CAE solves are six of the site's four tokens held in turn.

PLATE_REL_TOL is 1e-04 and it is read off the study in three steps, each a measurement:

1. at the coarsest seed the two solvers' mid-span deflections differ by 7.65e-04, at the
   finest by 3.16e-05, and nothing about the translation changed between those runs. A
   tolerance able to pass the coarse mesh would have to be ~9e-04, 50x the difference
   the converged answers have -- it would be admitting a real 5e-04 error as noise;
2. the order is measured, not assumed: 4.0000 (Sestra) and 3.9997 (Abaqus) error ratios
   on the bare strip, p = 2.0000 and 1.9999, and a sequence outside ORDER_BAND raises;
3. the extrapolants then agree to 1.720e-05 on the bare strip and 1.271e-05 on the
   stiffened one. Over all significant components of all seven probes and both variants
   the worst is 1.81e-05. So 1e-04 is 5.5x the residual a correct translation leaves,
   and 100x below the smallest thing it exists to catch.

    bare, mid-span u3, closed form 0.173333333333
      seed     Sestra FQUS     rel        Abaqus S4R      rel
      0.125    0.1731979102    7.813e-04  0.1730654836    1.545e-03
      0.0625   0.1732994765    1.953e-04  0.1732686013    3.735e-04
      0.03125  0.1733248681    4.884e-05  0.1733193845    8.047e-05
      Richardson 0.1733333194  8.02e-09   0.1733363138    1.720e-05

    stiffened, closed form 0.065200287132
      0.125    0.0651635677    5.632e-04  0.0651114956    1.362e-03
      0.0625   0.0652009770    1.058e-05  0.0651863739    2.134e-04
      0.03125  0.0652091727    1.363e-04  0.0652047023    6.772e-05
      Richardson 0.0652114719  1.715e-04  0.0652106428    1.588e-04

PLATE_MESH_REL_TOL is a looser 1e-03 for the single-mesh table, which is printed for the
diagnosis it gives and not as the verdict. It is applied to the finest mesh, where the
worst component is 7.21e-05, and deliberately NOT to the coarser two, whose 8.39e-04 gap
would sit just inside it. A tolerance a coarse mesh only just passes is not a tolerance.

Four guards, each for a way a shell comparison goes wrong that a beam comparison cannot:

* assert_cylindrical -- the three mid-span probes must carry the same u3. This is what
  says D with its 1 - nu^2 is the right closed form. Without the long-edge constraint the
  strip is up to 9% off and every number still looks like a plate under pressure -- and
  both solvers, given the same wrong boundary, would agree beautifully. Measured: the
  bare strip's spread is exactly 0.0 in both solvers at every density; the stiffened
  strip's is 8.1e-05, and that variation is the model, not an element;
* assert_stiffener_present -- the measured stiffness ratio against
  EI_plate / (EI_plate + EI_bar) = 0.376156. A stiffness check and not an element count,
  because the way a stiffened shell model goes wrong is that the bar is there and
  carrying nothing: unmerged nodes, an ordinary edge instead of a Stringer, or the bare
  model solved twice all give 1.000, and a bar rotated 90 degrees gives 0.924. Measured
  0.376220 (Sestra) and 0.376209 (Abaqus), 1.7e-04 and 1.4e-04 away;
* assert_reaction_total -- the solver's own reaction against -q L b, at 1e-06, 44x the
  worst residual either leaves. Compared with its sign, not by magnitude: a reaction with
  the load's sign would mean the supports were pushing the plate down;
* NotASequence -- extrapolate refuses a list that is not one solver's one variant coarse
  to fine. A distinct exception from NotConverging on purpose: a list mixing two variants
  also happens to turn around, so a test accepting either could not tell whether the
  structural check was still there. Found by mutation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
…t twelve measured solves

Five new self-test groups, 74 checks, taking the package from 35 to 109. They need no
solver and no licence: the plate groups carry the measured output of twelve real solves
(Sestra FQUS and Abaqus S4R, three seeds, bare and stiffened) as constants, which is what
makes PLATE_REL_TOL checkable in the ordinary suite rather than only by hand.

check_plate_closed_forms is identities and limits, computed from the literals of the
problem so it is not the code agreeing with itself: D against E t^3 / (12 (1 - nu^2)), the
support rotation and the deflection in the ratio 3.2 / L, EI_bar against adapy's own FB
section, w_stiff / w_bare exactly EI_plate / sum EI, an exact h^2 sequence reading back
order 2.0 and extrapolating to its own limit, an h^3 one reading back 3.0, and the
consistent nodal load coming out 0.25 / 0.5 / 1.0 on unit cells.

check_plate_convergence is where the tolerance stops being a feeling: the four measured
sequences' orders and extrapolants, the fact that refining softens both elements
monotonically (they approach from below), that extrapolating gets *closer* to the closed
form than the finest mesh on the bare strip, that PLATE_REL_TOL sits between the measured
1.805e-05 and the coarse-mesh 8.395e-04 in absolute terms -- and that the COARSEST mesh
pair would FAIL at it. That last one is what says the extrapolation is doing the work; a
tolerance the coarse mesh passes would make the convergence study decoration.

check_plate_boundary_semantics pins what "simply supported" means in the Abaqus deck,
literally rather than rebuilt from EDGE_SUPPORTS -- rebuilding it would make the check
move with the data it is checking. Positive: SS_X0 is u1 = u2 = u3 = 0, SS_X1 is
u2 = u3 = 0, CYL is u2 = 0, ur1 = 0. Negative, and just as load-bearing: no ur2 or ur3
anywhere, because that is what makes the support simple and fixing it is 5x.

check_plate_loud_failures runs every guard against the input it exists for: a FEM with no
shells at all, a probe on no node, two coincident nodes at one, the stiffener missing from
one side and the same stiffener rotated 90 degrees, an all-zero bare table, a 1% fan-out
across the width, three zero mid-span probes, half the load reacted and the load reacted
with the wrong sign, four malformed refinement sequences, a component identical at all
three meshes, a detached stiffener in adapy's own mesh, and the plate probe set compared
against the portal frame's.

20 mutations run, 20 caught. Five of them were written because the first pass of these
checks passed with the bug present, and the fix in each case was the check rather than
the tolerance:

* a turn-around sequence at |d1/d2| = 2 is rejected by ORDER_BAND, so deleting the sign
  clause of observed_order changed nothing. The check now uses (1.0, 1.05, 1.0375), whose
  ratio is 4.0 and which only the sign clause can reject; and a separate first-order
  sequence now pins ORDER_BAND itself, which nothing had;
* a list mixing two variants also turns around, so deleting extrapolate's structural check
  left all four sequence guards passing. plate_compare.NotASequence is now its own
  exception, deliberately not a NotConverging, and the four checks name it;
* re-raising a non-convergence unwrapped keeps the type and loses the address, so a check
  on the type alone could not see it. The message must now name the probe and component;
* unit cells are the one case where assuming mesh_size**2 gives the right tributary area,
  so the grid helper now takes a cell size and a 0.5 x 0.25 case is checked;
* dropping ur1 from the CYL support -- which frees the long edges and makes D the wrong
  stiffness by 9% -- was caught by nothing at all licence-free, which is what
  check_plate_boundary_semantics exists for.

tests/core/: 4716 passed, 22 skipped, 0 failed. Lint clean.

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

Rebased onto feat/abaqus-cae-region-supports (adapy PR Krande#405), which taught the
writer to carry a Bc along a plate edge and over a plate's faces. That closes the
one gap this case worked around, so the workaround goes.

WHAT WENT. plate_abaqus_runner.support_driver -- 150 lines of Abaqus-kernel Python
appended to the writer's own script, which built three assembly Sets of geometry
edges by getByBoundingBox, created the DisplacementBCs, submitted the job, read
the ODB and wrote a displacement sidecar in the writer's own schema -- together
with _DRIVER_TEMPLATE, _cae_set_name, EDGE_BOX_TOL, CHECKS_NAME, the
plate.cae_checks.json sidecar, reproduce_edge_support_refusal and the
--reproduce-refusal option it was reached by. The Abaqus half now emits the model
with submit=True and reads the writer's OWN two sidecars:
plate.cae_displacements.json for the answer and plate.cae_build_result.json for
the provenance (reaction total, node count, elements by type, region kinds and
region_edges). build_strip no longer takes with_edge_supports, and the route
decides only the form of the load.

Against the rebased writer the reproducer already said so itself:

    AssertionError: the CAE writer accepted a Bc on this strip's supported edge,
    which it refused when this module was written [...] That is good news [...]
    plate_abaqus_runner.support_driver should go.

WHAT THE MEASUREMENT WAS KEPT FOR. findAt returning half a split edge is still
why the regions are located by box, and it is still this model's own hazard, so
it stays -- now as the writer's numbers rather than the driver's, read back off
the kernel and checked there against what adapy computed from its own body:

    region     bare strip      stiffened strip
    SS_X0      1 edge, 0.5     2 edges, 0.5     <- the bar splits each end
    SS_X1      1 edge, 0.5     2 edges, 0.5
    CYL        2 edges, 8.0    2 edges, 8.0

Half a simple support moves the answer about 10% and leaves every number in the
report plausible, which is why read_checks refuses a run whose three supports did
not all arrive as 'edge'.

NEW GUARDS FOR THE NEW ROUTE. The supports are now the model's own Bc records on
both routes and nothing else, so plate_model.assert_edge_supports_declared checks
all three are present with their dof lists before a licence is spent on the model
-- two of three supports still solves. The self-test's boundary-semantics group
now emits the plate's CAE script (no licence needed) and asserts the writer's own
three DisplacementBC calls literally, its three edge regions with the counts and
lengths above, that no support resolved to a vertex region, and that this package
has no driver left to append. 109 checks -> 116, MINIMUM_CHECKS raised to match.

Self-test 116/116; tests/core/test_genie_vs_abaqus_selftest.py 10 passed.

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

Six Abaqus solves and six Sestra ones, both variants at all three seeds, from the
repository root:

    python -m verification.genie_vs_abaqus.run_comparison --case plate \
        --work-dir D:\temp\gvsa_plate_restack

Every Abaqus number is BIT-IDENTICAL to the one the appended support_driver
produced -- not to the printed digits, to the double. Compared against the
constants selftest.py carries from that run, and against the driver's own
plate.cae_checks.json sidecars still on disk from it:

    variant / seed   u3(MID), writer's supports   driver's   identical
    bare  0.125      -0.17306548357009888         same       yes
    bare  0.0625     -0.17326860129833221         same       yes
    bare  0.03125    -0.17331938445568085         same       yes
    stf   0.125      -0.06511149555444717         same       yes
    stf   0.0625     -0.06518637388944626         same       yes
    stf   0.03125    -0.06520470231771469         same       yes

and so are the reaction totals (e.g. bare 0.125 (-1.1368683772153844e-13,
4.618527782439125e-12, 2000.0) from both), the node counts (165/585/2193) and the
element counts ({'S4R': 128}, {'B31': 32, 'S4R': 128}, ...). The extrapolants are
-0.1733363138306436 and -0.06521064275691632, i.e. the docstrings' 0.1733363138
and 0.0652106428 unchanged, and the hand checks pass at 1.720e-05 / 1.588e-04
against 1e-04 / 1e-03. Cross-solver, 0 of 42 components failed in either variant:
worst QTR.u3 1.805e-05 (bare) and X0_MID.r2 1.755e-05 (stiffened).

The Sestra side is untouched and measured so: all six of its mid-span deflections
are bit-identical to the pinned sequences as well. Run twice (the case was solved
a second time into the brief's own work directory), the only numbers that moved
were Sestra rotations at 1e-10 and below -- every one of them below the
comparator's 1e-06 absolute floor and reported as "zero"; no Abaqus number moved
at all.

The regions the writer built, read back off the kernel in region_edges:

    SS_X0 / SS_X1   1 edge of 0.5 bare, 2 edges of 0.5 stiffened
    CYL             2 edges of 8.0 both

and region_kinds 'edge' for all three at every seed -- which is what read_checks
now refuses a run without.

THE ONE CORRECTION. Two docstrings said the Abaqus in-plane reaction components
sit "at 1e-13". Measured over all six solves they run from 1.1e-13 to 4.6e-12
(the driver's own sidecars say the same, so this was always the number); both now
say 1e-13 .. 5e-12. No tolerance anywhere depends on it.

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

Closing: the Abaqus/CAE writer is not going into adapy. See #407, which folds the format-level FEA
PRs and deliberately leaves this stack out.

Why. adapy supports formats, not APIs — and least of all APIs into proprietary software.
src/ada/cadit/cae/ emits a Python script for the Abaqus/CAE kernel, and verification/genie_vs_abaqus
drives it with abaqus cae noGUI=. A verification report entry that only a CAE licence can reproduce
is not a verification anyone can check, which is the opposite of what the report is for. adapy's job
here is reproducible FEA: write the deck, let any solver read it.

This is a decision about scope, not about the work. The measurement in it is careful — the probed CAE
profile argument orders, ChannelProfile being rejected by Abaqus' own preprocessor, the stringer-vs-
ordinary-edge element counts, the tapered segfault. None of that is disputed; it just lives on the
wrong side of the line.

Nothing is lost. The branch stays on the fork, so any of it can be recovered or taken up
elsewhere (a plugin outside adapy would be the natural home).

What was salvaged, because it was format-level and only reachable through this stack: a
T-profile now reaches an Abaqus *Beam Section. It had no mapping at all before —
Section type "T" is not added to Abaqus beam export yet — so a model with a T stiffener could not be
analysed through Abaqus or Calculix. #407 writes it as section=I with the bottom flange zeroed
(Abaqus has no section=T; its own preprocessor spells it that way), and the zeroing is the point:
adapy stores a T with w_btn = t_w and t_fbtn = t_ftop, so the ordinary I data line would have
described a stub flange the model does not have, with nothing raised. Calculix converts a T to a
general section there too, as it already did an I.

sections/profiles.py's ProfileSpec was not taken. Its 480 lines are a registry of CAE kernel
classes (CAE_PROFILE_ARGUMENTS, cae_kwargs_source, CAE_PROFILE_CLASSES_THE_SOLVER_REJECTS) whose
only consumer is this writer, and the write_sections refactor exists to feed it — keeping them would
leave a dead proprietary-API registry inside ada.sections. The one genuine fix in that file,
eval_general_properties returning a copy and treating a computed zero Iyz as data, is #396, which
is folded into #407.

The plate work this stack was building toward is in #407, done through the deck route: an
ada.Plate meshed by the meshing module, a static pressure case at three seeds and an eigenvalue case
across solvers, against 5 q L⁴ / (384 D) and f_n = n² π / (2 L²) √(D / (ρ t)). Code_Aster lands on
the eigen closed form to six digits. Getting there exposed five real writer gaps — Calculix had no
pressure load at all, Code_Aster's had never run, Code_Aster refused eigen analysis with more than one
Bc, and the BEUSLO sign was inverted — which the CAE route could not have found.

@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