Repository navigation
Conversation
… 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)
PR Review👋 I checked your PR and found no issues. Thanks!
|
🚀 Profiling Results (Top 20 most expensive calls)
|
…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
…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
927b0d6 to
dede913
Compare
|
Closing: the Abaqus/CAE writer is not going into adapy. See #407, which folds the format-level FEA Why. adapy supports formats, not APIs — and least of all APIs into proprietary software. This is a decision about scope, not about the work. The measurement in it is careful — the probed CAE Nothing is lost. The branch stays on the fork, so any of it can be recovered or taken up What was salvaged, because it was format-level and only reachable through this stack: a
The plate work this stack was building toward is in #407, done through the deck route: an |
…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
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 plateon the existingrun_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
a6dbf3420onward — 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 = 0on both long edges (cylindrical bending). Two variants: bare, and with a 10 × 45 mm flat bar on the centreline — aStringerin CAE, aBEASsharing 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−ν²)):Stiffened, parallel-spring
5(qb)L⁴/(384(EI_p+EI_b)) = 0.065200287: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.138666667against 0.138666745 (Sestra, 5.6e-08) and 0.138666648 (Abaqus, 1.3e-07); the stiffness ratioEI_p/ΣEI = 0.376156against 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
load_strreports it OMITTED and returns""; there is noBEUSLO/BELOADin 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/4for a bilinear quad, soqA/4per node is the pressure's vector), stated in the docstrings and checked: threeLoadobjects summing to −2000.000 N, each solver's reaction total 2000.0. ABEUSLOwriter is in progress separately.analysis._resolve_regionresolved 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 ownBcrecords 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, andfindAtat the midpoint returns one of them.Also:
sestra_runner's "gap 2" note is out of date (load_forceloops 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_BANDso the sign clause was untested; a list mixing two variants also turns around, so deletingextrapolate's structural check changed nothing (now a distinctNotASequence); an unwrapped re-raise kept the type and lost the address; unit cells are the one case where assumingmesh_size²gives the right tributary area; and droppingur1from theCYLsupport — 9% on the answer — was caught by nothing, which is whatcheck_plate_boundary_semanticsnow exists for. The rest:Dwithout1−ν²,EI_barfromIz, the stiffener ratio check disabled, a 10% cylindrical tolerance, a magnitude-only reaction check,PLATE_REL_TOLraised to 1e-02 (including "the coarsest mesh must fail"),assert_has_shellscounting all elements,qAinstead ofqA/4, the connectivity check weakened,assert_probes_are_seededdisabled, a fixed rotation at a "simple" support, the Abaqus route handed the Sestra load form.Self-test 35 → 116 checks,
MINIMUM_CHECKSraised with it, all intests/core/test_genie_vs_abaqus_selftest.py. Suitetests/core/: 4716 passed, 22 skipped, 0 failed; lint clean.Deliberately left out
S4Rthe softer at every density).plates.pysupports one; its closed form is not clean; a workstream of its own.🤖 Generated with Claude Code
https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv