Skip to content

feat(fem): a *Tie becomes BLDEP, interpolated over the main facet it lands on - #394

Closed
oleandor wants to merge 6 commits into
Krande:mainfrom
oleandor:feat/abaqus-free-face-surfaces
Closed

oleandor wants to merge 6 commits into
Krande:mainfrom
oleandor:feat/abaqus-free-face-surfaces

Conversation

@oleandor

@oleandor oleandor commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Reworked onto #386 (0.91.0), and it now carries the Sesam tie writer as well.

Stacked on #393, which fixes the direction of a *Tie's two sides. That fix has to come first:
this PR is what makes the sides observable, because before it a tie was left out of the Sesam
deck entirely (a "tie" constraint has no Sesam form) and a backwards tie wrote nothing.

The nearest-node tie writer that used to live in #393 is gone. It would have been written there and
replaced here — the same code twice — so the tie writer lands once, in its final form, with the
nearest-node arm kept only as the fallback it is.

ada/fem/surfaces.py rebased onto #386's version cleanly: #386 had already added
side_node_indices, _region_groups and surface_nodes, and this builds on exactly those.

A *Tie is not a rigid arm to the nearest node

Abaqus ties a secondary surface to a main surface by projecting each secondary node onto the main
surface and interpolating between the nodes of the facet it lands on. The rigid arm to the
nearest main node — which is what this writer does for a shell-to-solid coupling — is off by
16.7 mm on average on the acceptance deck, of the order of half a plate element, and no measure
can turn it into the right answer because the nearest node of a facet is simply not where Abaqus
ties.

Sesam can express the real thing. Several BLDEP records may name the same dependent node with
different independent nodes and Sesam sums them (manual §7.2.14), so one record is written per
facet node, each carrying that facet's shape-function weight at the projected point times the rigid
arm to that node:

u_slave   = sum_k w_k [ u_k + theta_k x (x_slave - x_k) ]
phi_slave = sum_k w_k phi_k                        (when both sides carry rotations)

The weights sum to 1, and that is the load-bearing property: it is what makes any rigid-body motion
of the main surface pass through exactly, whatever the weights happen to be. Where the main
surface has no rotational DOFs (a solid mesh) the relation reduces to u_slave = sum_k w_k u_k,
which is Abaqus' tie constraint term for term. It is asserted on the records, not taken on trust.

Two consequences of having facets at all:

  • position tolerance can finally be compared with what it means — a distance to the main
    surface. The nearest-node path needs a node-spacing slack purely to make the units of its
    comparison agree, and that slack cannot tell a 20 mm in-plane offset from a 20 mm gap.
  • ada.fem.surfaces grows surface_facets, which shares one reading of a region with
    surface_nodes, so the two cannot disagree about what a surface covers. The cutoff and the node
    set now come from the same reading; they did not.

adjust is reported, not honoured: it tells Abaqus to move secondary nodes, ada never moves nodes,
and holding a node rigidly off the facet is exact for rigid-body motion. Treating adjust=yes as an
infinite tolerance would tie nodes Abaqus leaves free.

Where a main surface offers no facet — a NODE-type surface, a plain node set, or (rarer) facet
topologies this writer has no shape functions for — the nearest-node arm is used and reported as the
approximation it is, with its distance distribution, and the finding says which of those two ways
it got there.

The free-face rule

ELSET, — an element surface entry naming no face identifier — is valid Abaqus, and for a continuum
element it means that element's free faces. It reached int("") - 1 and took the whole
*Surface down with a bare ValueError; an entry written without the trailing comma at all arrived
as the float 1.0 and raised AttributeError.

It is read now as the blank side — which is not the same thing as no side — and resolved to the
free faces. Adjacency comes off the elements' own Node._refs back-references rather than a
mesh-wide face table: 23 ms on the user's 460,738-element deck for the 132-element entry that
motivated this, against 8.6 s to hash all 946,021 solid faces; both agree on all 132 free faces.
Where the free faces genuinely cannot be determined (Node._refs not carrying the elements, or a
neighbour with no Abaqus face numbering) the answer is a suspect finding, not a guess — taking
the whole element instead pulls in nodes on faces interior to the body, which are on no surface at
all. The Abaqus writer round-trips the blank side rather than inventing a face number for it.

Evidence

  • tests/core: 0 failed. Clean 0.91.0 is 4265 passed / 0 failed.
  • The Sestra-proven deck. Against a baseline captured from plain 0.91.0 (344,131,339 bytes,
    md5 dab3c17db89e10a65a0a808ddd7dab92), the whole 344 MB file is identical but for the timestamp
    line. That deck has no *Tie and no side-less surface entry, so this adds nothing to it — which is
    the point of checking.
  • Every test was checked by reverting the change it guards and watching it fail: ten cases, including
    dropping the shape-function weight from the arm (caught by the rigid-body-motion test), keeping a
    zero weight, reading a side-less entry as the whole element, and calling a face free without
    looking at its neighbours.
  • black / ruff / isort at 120, clean.

One thing left on the table

bldep_records's _merged folds records sharing a (slave, master) pair by concatenating their
terms. If two constraints declare the same (slave_dof, master_dof) on the same pair, Sesam sums the
two coefficients — so the pair is written once, as the manual requires, but with a doubled
dependency. That is #386's merge, not new here, and bldep_records already raises a suspect when
two constraints claim one dependent DOF. Worth a follow-up that drops the repeated term and says
so, rather than folding it in.


🤖 Generated with Claude Code

https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv

@github-actions

github-actions Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

PR Review

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

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

@github-actions

github-actions Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

🚀 Profiling Results (Top 20 most expensive calls)

Function Calls Duration (s)
<built-in method builtins.exec> (~:0) 1373 21.1442
<module> (<string>:1) 1 21.1442
test_bench_batch_tessellate (tests/profiling/test_cad_backend_bench.py:63) 1 6.2829
run (tests/profiling/test_cad_backend_bench.py:73) 6 6.0995
batch_tessellate (src/ada/visit/tessellate.py:1102) 1446 6.0985
test_build_big_ifc_pipe (tests/profiling/test_ifc_creation.py:48) 1 6.0037
__truediv__ (src/ada/api/spatial/part.py:2046) 8 5.7869
tessellate_geom (src/ada/visit/tessellate.py:830) 1440 5.5465
tessellate_occ_geom (src/ada/visit/tessellate.py:684) 1440 5.0149
tessellate_shape (src/ada/visit/tessellate.py:554) 1440 5.0020
to_ifc (src/ada/api/spatial/assembly.py:325) 5 4.6193
sync (src/ada/cadit/ifc/store.py:200) 5 4.3299
sync_added_physical_objects (src/ada/cadit/ifc/write/write_ifc.py:92) 5 4.2754
add (src/ada/cadit/ifc/write/write_ifc.py:582) 2200 4.1998
add_object (src/ada/api/spatial/part.py:309) 1201 4.0689
add_pipe (src/ada/api/spatial/part.py:164) 200 4.0513
tessellate (src/ada/cad/__init__.py:1187) 1440 4.0460
add_section (src/ada/api/spatial/part.py:294) 801 3.4709
add (src/ada/api/containers/sections.py:139) 805 3.4696
equal_props (src/ada/sections/concept.py:100) 241399 3.1757

@oleandor

Copy link
Copy Markdown
Contributor Author

Correction to the test evidence in the description above.

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

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

passed failed
clean main at 0.88.0 3852 0

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

🤖 Generated with Claude Code

https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv

oleandor and others added 5 commits September 26, 2026 17:55
Abaqus' *Tie data line is `secondary, main`. The reader put the FIRST surface into
`m_set` and the writer wrote `m_set, s_set` back out, so the two mistakes cancelled:
the Abaqus round trip agreed with itself while both ends were wrong, and no test could
see it. Every other consumer of the constraint could: the Sesam writer's
`coupling_records` and `shell2solid_records` both take `m_set` for the independent
side, so a tie converted on that basis came out with the dependent and independent
sides exchanged -- a model that still runs, still analyses, and is wrong.

The convention (`m_set` independent, `s_set` dependent, for every constraint type) is
now written down where the constraints are read, and pinned by a test from the deck
through to the BLDEP records rather than by a round trip, which a pair of mirrored
mistakes passes.

Alongside, three things the reader did to a constraint quietly:

* a *Coupling followed by *Distributing (three spellings) matched nothing and the whole
  constraint left the model on a log line. It is read now, which sub-keyword it was is
  kept, the Abaqus writer puts the same one back, and the Sesam writer says that a
  distributing coupling became rigid links instead of writing one as the other.
* a *Kinematic data line of one DOF raised ValueError out of reshape(-1, 2) and took the
  import down; an empty one is all six DOFs, and says so.
* *Tie's `tied nset` and `cyclic symmetry` narrow which secondary nodes are constrained,
  nothing acts on either, and every writer ties the whole region. Reported per tie.

And *System is applied: it is not a construct that goes missing, it MOVES THE MESH.
Nodes under `*System  0., -660., 0.` were read at the origin. The keyword census could
say *SYSTEM went unread; it could not say the nodes were in the wrong place. The
6-number off-axis case leaves the local Y and Z axes undetermined and is reported
rather than guessed at.

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

The *System tests only reached get_nodes_from_inp_arrays, so removing the transform from
get_nodes_from_inp left all twelve of them passing. Both readers are now driven directly,
with an assertion that they agree.

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

SLIDER takes three nodes; a Constraint holds one node pair, so the third was dropped in
silence -- the constraint that came out was not the one the deck wrote, with only the two
nodes it kept to show for it. It is reported now, per block, with the count and the types.

Also narrows the constraint-sides note to the types it is actually true of. *MPC has the
same inversion *Tie had -- Abaqus eliminates the FIRST node of a data line, and the reader
puts it in m_set -- but it is latent: no writer reads an MPC's sides as
independent/dependent, and flipping it would also flip what convert_ecc_to_mpc writes for
every beam offset, which of its two nodes it means to eliminate being established nowhere.
Named rather than changed blind.

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

A tie was left out of the Sesam deck altogether: `a "tie" constraint has no Sesam form`.
It has one. Abaqus ties a secondary surface to a main surface by projecting each secondary
node onto the main surface and interpolating between the nodes of the FACET it lands on,
and Sesam can say exactly that -- several BLDEP records may name the same dependent node
with different independent nodes and Sesam sums them (manual 7.2.14). So one record is
written per facet node, carrying the facet's shape-function weight at the projected point
times the rigid arm to that node. The weights sum to 1, which is what makes a rigid-body
motion of the main surface pass through exactly.

The rigid arm to the nearest main *node* -- what this module does for shell-to-solid
coupling -- is not where Abaqus ties: 16.7 mm out on average on the acceptance deck, of
the order of half a plate element. It stays as the fallback for a main surface with no
facets to project onto (a NODE surface, a plain node set), which is then reported as the
approximation it is, with its distance distribution.

Two consequences of having facets at all:

* `position tolerance` can finally be compared with what it means -- a distance to the
  main SURFACE. The nearest-node path still needs a node-spacing slack to make the units
  of its comparison agree, and that slack cannot tell a 20 mm in-plane offset from a
  20 mm gap.
* `ada.fem.surfaces` grows `surface_facets`, sharing one reading of a region with
  `surface_nodes` so the two cannot disagree about what a surface covers.

And the free-face rule: `ELSET,` -- an element surface entry naming no face identifier --
is valid Abaqus and means, for a continuum element, its FREE faces. It reached
`int("") - 1` and took the whole *Surface down with a bare ValueError. Read as the blank
side, resolved to the free faces from the elements' own node back-references (23 ms on a
460,738-element deck, against 8.6 s to hash every solid face of the mesh), and reported as
suspect where they cannot be determined rather than silently taken as every node of the
element -- which reaches into the interior of the body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
The reader tests pin the reader, test_write_tie pins the writer against hand-built
constraints, and the defect lived in the join between them: the reader put the deck's
first surface into m_set and the writer takes m_set for the independent side. This reads
a deck and asserts on the BLDEP records -- the dependent nodes are the ones the deck
named first -- and, because the main surface is element-based, that a dependent node
reaches the four nodes of the facet it projects onto with weights summing to 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nej3bWxJevs9GkH6tg1WKv
@oleandor oleandor changed the title feat(fem): make an Abaqus *TIE exact by interpolating over the main facet feat(fem): a *Tie becomes BLDEP, interpolated over the main facet it lands on Sep 26, 2026
@oleandor
oleandor force-pushed the feat/abaqus-free-face-surfaces branch from 6fa2236 to dc1a005 Compare September 26, 2026 17:22
@oleandor
oleandor marked this pull request as ready for review September 26, 2026 19:36
@Krande

Krande commented Sep 27, 2026

Copy link
Copy Markdown
Owner

Folded into #407.

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

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

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

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

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

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

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

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

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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants