Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions data/coupling_participants/participant_dune_elastic.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,8 +531,8 @@ def sigma(w):
print(f"[dune {SIDE}] interface n={len(U)} "
f"ux=[{U[:, 0].min():.6g},{U[:, 0].max():.6g}] "
f"uy=[{U[:, 1].min():.6g},{U[:, 1].max():.6g}] "
f"tx=[{Q[:, 0].min():.6g},{Q[:, 0].max():.6g}] "
f"ty=[{Q[:, 1].min():.6g},{Q[:, 1].max():.6g}]")
f"qx=[{Q[:, 0].min():.6g},{Q[:, 0].max():.6g}] "
f"qy=[{Q[:, 1].min():.6g},{Q[:, 1].max():.6g}] (export q = -(sigma . n_own))")

# exports.json LAST: the driver takes its existence as proof of success.
# THE RUN-LOG CONTRACT LINE: `NDOF = <integer>` on a line of its OWN.
Expand Down
9 changes: 6 additions & 3 deletions data/coupling_participants/participant_febio_elastic.py
Original file line number Diff line number Diff line change
Expand Up @@ -696,11 +696,14 @@ def parse_log(path, ncol):
print(f"[febio {SIDE}] interface n={len(U)} "
f"ux=[{U[:,0].min():.6g},{U[:,0].max():.6g}] "
f"uy=[{U[:,1].min():.6g},{U[:,1].max():.6g}] "
f"tx=[{Q[:,0].min():.6g},{Q[:,0].max():.6g}] "
f"ty=[{Q[:,1].min():.6g},{Q[:,1].max():.6g}]")
f"qx=[{Q[:,0].min():.6g},{Q[:,0].max():.6g}] "
f"qy=[{Q[:,1].min():.6g},{Q[:,1].max():.6g}] (export q = -(sigma . n_own))")

# PER-LEVEL PERSISTENCE: this level's interface and field, named by LEVEL. Interpolate
# THESE onto your probe points.
# THESE onto your probe points. THE qx, qy COLUMNS ARE THIS SIDE'S EXPORT,
# q_out = -(sigma . n_own) (the sign convention at the top of this file): a task that
# asks for the traction sigma . n wants their negative, and one that fixes a single
# normal for both sides flips the side whose own normal points the other way.
try:
with open(f"interface_level{LEVEL}.csv", "w") as _f:
_f.write("x,y,ux,uy,qx,qy\n")
Expand Down
4 changes: 2 additions & 2 deletions data/coupling_participants/participant_fenics_elastic.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,8 +502,8 @@ def sigma(w):
print(f"[fenics {SIDE}] interface n={len(U)} "
f"ux=[{U[:,0].min():.6g},{U[:,0].max():.6g}] "
f"uy=[{U[:,1].min():.6g},{U[:,1].max():.6g}] "
f"tx=[{Q[:,0].min():.6g},{Q[:,0].max():.6g}] "
f"ty=[{Q[:,1].min():.6g},{Q[:,1].max():.6g}]")
f"qx=[{Q[:,0].min():.6g},{Q[:,0].max():.6g}] "
f"qy=[{Q[:,1].min():.6g},{Q[:,1].max():.6g}] (export q = -(sigma . n_own))")

# THE RUN-LOG CONTRACT LINE: `NDOF = <integer>` on a line of its OWN.
# The audit reads that exact shape, and they read it PER
Expand Down
2 changes: 1 addition & 1 deletion src/backends/fourc/deck_grammar.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@
finished normally'); put the task's thermal sources and boundary
conditions on that slab in place of its volume-wide temperature, keep the
thermal expansion from THEXPANS/INITTEMP (alpha = beta/(3*lambda+2*mu)
for a task that states beta), and exchange [T, ux, uy] / [q_n, t_x, t_y]
for a task that states beta), and exchange [T, ux, uy] / [q_n, q_x, q_y]
per interface point as the coupling contract says. The alternative of two
separate problem types (`Structure` with WALL elements, 2-D SOLID from
2026.2.0 on, and `Thermo` with THERMO elements, exchanging the thermal strain
Expand Down
46 changes: 34 additions & 12 deletions src/tools/consolidated.py
Original file line number Diff line number Diff line change
Expand Up @@ -1941,7 +1941,9 @@ def _stamp_verification(result: dict, *, evidence_ok: bool, reason: str = "",
"reply already carries `residual_csv` (the measured "
"iteration history), `interface_csv` (your interface tables) "
"and `captured_solver_logs` (the log paths). SAVE THOSE "
"VERBATIM as this level's deliverables, then run the NEXT "
"VERBATIM as this level's deliverables -- except an interface "
"table's q columns, which take the sign its _how_to_use states "
"for the quantity your task names -- then run the NEXT "
"level. A one-row or hand-written residual file reads as "
"no coupling at all — the real multi-row history is in this "
"reply. Report the caveat ALONGSIDE the saved files; it is "
Expand Down Expand Up @@ -3194,6 +3196,10 @@ def _unsaved_work_notice(work_dir, out_files) -> str | None:
from .knowledge import _physics_tail # noqa: E402
from .knowledge import _UNIVERSAL as _UNIVERSAL_BLOCK # noqa: E402
from .knowledge import _UNIVERSAL_CORE as _UNIVERSAL_CORE # noqa: E402
# one statement of what the exported flux columns hold, in every reply that sends the agent
# from them to the task's files (its comment in coupling_knowledge says why)
from .coupling_knowledge import FLUX_COLUMNS_SIGN as _FLUX_COLUMNS_SIGN # noqa: E402
from .coupling_knowledge import FLUX_COLUMNS_SIGN_SHORT as _FLUX_COLUMNS_SIGN_SHORT # noqa: E402


# THE FIRST 1500 CHARACTERS DECIDE THE RUN, SO THEY GO FIRST.
Expand Down Expand Up @@ -7513,7 +7519,7 @@ async def couple(participants: str | list, max_iter: int = 50, tol: float = 1e-6
``iteration,interface_residual``. Use the path of the per-level
residual-history file your task names; never retype the returned history.

iface_level: optional level number stamped into the suggested_filename of the interface_csv blocks the reply carries on convergence (each participant's own final interface data, ready to save verbatim).
iface_level: optional level number stamped into the suggested_filename of the interface_csv blocks the reply carries on convergence (each participant's own final interface data, ready to save; their _how_to_use states the sign of the q columns -- for a served Dirichlet-Neumann elastic side, MINUS the outward traction).

pde_check: THE ONE CHECK THAT SEPARATES A RIGHT ANSWER FROM A CONVERGED WRONG ONE, run for you on every converged level. JSON keyed by participant name: {"A": {"solution_files": "<this side's per-level field files, comma-separated, in level order>", "equation": "<your task's EQUATION line, verbatim>", "source": "<that side's source, as your task wrote it>", "coefficient": "<that side's coefficient>", "domain": "[[x0,x1],[y0,y1]]"}, "B": {...}}. The files must be on the PROBE GRID your task prescribes, not your mesh nodes: the test is a midpoint quadrature and on a scatter it reports nothing rather than a misleading number. You name them; openPASO guesses no filename and opens no task file. Give it once and openPASO puts each side's own field back into that side's own equation at every level from the second on, and reports CONSISTENT or INCONSISTENT in `pde_consistency`. A refinement study cannot do this: a field that converges cleanly to the WRONG function is indistinguishable in one -- measured on three recorded coupled runs whose every self-consistency measure reported a converging run while the answer was wrong. Nothing here is read from any task file; these are YOUR strings, and openPASO neither stores nor supplies them. A side whose operator this check does not model is refused by name and the rest still run.

Expand Down Expand Up @@ -7923,9 +7929,16 @@ def _flatrow(v):
cells += [f"{x:.15e}" for x in _flatrow(qs[i])]
rows.append(", ".join(cells))
ncoord = len(co[0]) if co and co[0] else 2
# ONE NAME PER COLUMN. A vector exchange was headed "x, y, u, qn" over six
# columns: an elastic side's two displacement and two traction components sat
# under two names, and a block saved as it stands could not be read by its
# header. Every entry of a point's values and normal_fluxes is named: a
# vector's components, or a waveform's time levels.
nu = len(_flatrow(vals[0])) if vals else 0
nq = len(_flatrow(qs[0])) if qs else 0
hdr = ", ".join(list("xyz"[:ncoord])
+ (["u"] if vals else [])
+ (["qn"] if qs else []))
+ (["u"] if nu == 1 else [f"u{c + 1}" for c in range(nu)])
+ (["qn"] if nq == 1 else [f"q{c + 1}" for c in range(nq)]))
_text = hdr + chr(10) + chr(10).join(rows) + chr(10)
_block = {"suggested_filename": f"interface{suffix}_{pname}.csv"}
if len(_text) > _inline_left:
Expand All @@ -7937,10 +7950,15 @@ def _flatrow(v):
if iface_csv:
iface_csv["_how_to_use"] = (
"Each block is that participant OWN interface data at "
"convergence, validated by this verdict. ONE DECISION "
"convergence, validated by this verdict: its coordinates, "
"its exported values (u, or u1, u2, .. one per entry of a "
"point's list) and its exported normal_fluxes (qn, or q1, "
"q2, ..). " + _FLUX_COLUMNS_SIGN_SHORT + " ONE DECISION "
"RULE: IF your participants exported AT the exact probe "
"points your task prescribes, save each csv field "
"verbatim with one write_file call and you are done. IF "
"points your task prescribes, save each csv field with "
"one write_file call -- verbatim, except its q columns, "
"which take the sign above for the quantity your task "
"names -- and you are done. IF "
"they exported at their own mesh nodes, do NOT save "
"these blocks as your reported interface files -- "
"follow the reporting key in this same reply, which "
Expand Down Expand Up @@ -8440,7 +8458,7 @@ def _num(v):
"coupled run is made unverifiable: the file is at the nodes, and "
"the list is not. A task that lists interior "
"points only has excluded the interface ENDS on purpose; do not "
"complete the list with them.")
"complete the list with them. " + _FLUX_COLUMNS_SIGN)

# ── ALWAYS-RUN CORRECTIVE FUNNEL: lead the reply with the next fix ────
# The driver verdict above says whether the ITERATION was sound; it does
Expand Down Expand Up @@ -8649,7 +8667,9 @@ def _num(v):
"the probe points your task prescribes and write that "
"side's per-level field file; (2) write each side's "
"per-level interface file at the prescribed interface "
"points from its own converged trace and flux; (3) make "
"points from its own converged trace and flux -- a served "
"Dirichlet-Neumann elastic side's q columns are MINUS the "
"outward traction, a heat side's qn is the outward flux; (3) make "
"each side's per-level run log the captured participant "
"log whose path this reply returns (the solver's own "
"console output), never a summary you type. A summary "
Expand Down Expand Up @@ -8923,7 +8943,8 @@ def _num(v):
"now and is the step most often lost when it is left to the end: the consoles stay on "
"disk, the logs the task asks for are never written, and a coupling that was proven is "
"read as not run. Then write the remaining "
"deliverables -- the field and interface files per side from each level's dumps -- in "
"deliverables -- the field and interface files per side from each level's dumps, "
"their q columns in the sign `reporting` states -- in "
"ONE pass at the end. A run log copied from another level's console reads as "
"an unchanged mesh and sinks the whole sequence, and the write check names it the "
"moment it is written; a level set with one level missing is read as no result at "
Expand Down Expand Up @@ -9488,7 +9509,8 @@ async def couple_levels(participants: str | list, levels: str | list, critic_app
"interface to the task's interface points with np.interp on the coordinate the interface "
"runs along -- griddata refuses points that lie on one line), and its per-level run log from "
"participant_output_level<k>.log plus the NDOF line; the coupling histories are already "
"at the history paths above. NO NaN GOES INTO A FILE: griddata(method='linear') returns NaN "
"at the history paths above. " + _FLUX_COLUMNS_SIGN + " "
"NO NaN GOES INTO A FILE: griddata(method='linear') returns NaN "
"at a probe outside the convex hull of the dump's nodes, and a probe on the side's edge can "
"miss that hull by round-off (measured: 1e-14 outside a unit-size box is enough); count "
"them with np.isnan before you write. A probe that round-off put outside is inside at "
Expand Down Expand Up @@ -12271,7 +12293,7 @@ def _re_words(text: str) -> list:
conditions is this partitioned domain decomposition however many fields each
subdomain carries -- temperature and displacement together included. Exchange
every interface quantity per point: values = [T, ux, uy] and normal_fluxes =
[q_n, t_x, t_y] on both sides (one row per interface point, the same order
[q_n, q_x, q_y] on both sides (one row per interface point, the same order
every iteration); the driver compares them component by component. A FIELD
coupling in which both codes own the whole body (the `tsi` route) is a
different setup and does not apply to a split domain.
Expand Down
38 changes: 37 additions & 1 deletion src/tools/coupling_knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -1631,6 +1631,34 @@ def consistent_interface_flux(nodes, tris, k, u, f_vol, iface_ids, h_trib):
relation in the participant scripts (see `knowledge(topic='tsi')`).
'''

# WHAT THE FLUX COLUMNS HOLD, SAID WHERE THE FILES ARE WRITTEN. A served Dirichlet-Neumann
# participant exports normal_fluxes, and dumps them into interface_level<k>.csv, as minus the
# conormal derivative on its own outward normal. For heat that IS the outward normal flux; for
# elasticity it is MINUS the outward traction sigma . n_own. The exchange needs exactly that (the
# two sides' exports cancel, and the Neumann side applies its partner's numbers unchanged), so it
# stays as it is. Found on an elastic coupling: the reply to a converged ladder told the agent to
# write the task's interface files from these columns and said nothing about their sign, so a file
# that asked for the outward traction sigma . n received its negative. Every instruction that sends
# the agent from the exported columns to the task's files carries this one statement; the
# regression test runs the elastic and the scalar contracts and checks the statement against them
# (tests/test_exchanged_traction_is_not_the_outward_traction.py).
FLUX_COLUMNS_SIGN = (
"SIGN OF THE FLUX COLUMNS. A served Dirichlet-Neumann participant exports normal_fluxes -- and "
"writes the q columns of its interface_level<k>.csv -- with respect to ITS OWN outward normal "
"n_own, in the sign the coupling exchanges (leave the exchange as it is): heat or diffusion "
"qn = -(k grad u) . n_own, which IS the outward normal flux; elasticity q = -(sigma . n_own), "
"the NEGATIVE of the outward traction sigma . n_own, so a file that asks for the traction gets "
"-q (a thermo-elastic dump's tx, ty already hold sigma . n_own). A task that fixes ONE normal n "
"for both sides flips the sign once more on the side whose outward normal is -n. An FSI "
"participant's sign is the one its own contract states.")
# The same rule in one sentence, for text that is repeated per level or per file (an interface
# table's _how_to_use rides on every level of a ladder reply).
FLUX_COLUMNS_SIGN_SHORT = (
"The q columns are each side's export as it is, on its own outward normal n_own; for a served "
"Dirichlet-Neumann participant -(k grad u) . n_own for heat or diffusion, the outward normal "
"flux itself, and -(sigma . n_own) for elasticity, MINUS the outward traction sigma . n_own, so "
"a file that asks for the traction gets -q (an FSI participant: the sign its contract states).")

_VECTOR = '''\
## 6a. VECTOR INTERFACES — displacement/traction, velocity/force, any n_comp

Expand All @@ -1656,6 +1684,11 @@ def consistent_interface_flux(nodes, tris, k, u, f_vol, iface_ids, h_trib):
partner's numbers UNCHANGED (`L += inner(g, v) * ds`), because the natural
boundary term of the elasticity weak form is +(sigma . n_own) . v. Exporting
the raw traction instead flips the sign the Neumann side applies.
That is the EXCHANGE convention, not the outward traction: sigma . n_own is
-q_out, so a file that asks for the traction gets both components negated when
you write it from the export or from the qx, qy columns of
interface_level<k>.csv. A task that fixes ONE normal n for both sides takes
sigma . n = +q_out on the side whose outward normal is -n.

**3. THE INTERFACE CORNERS BELONG TO THE OUTER BOUNDARY, ON BOTH SIDES.** A
node where the interface meets a constrained outer face is a constrained node
Expand Down Expand Up @@ -2837,7 +2870,10 @@ def _vector_block(script_name: str) -> str:
"interface point, in the same node order on both sides. The Dirichlet "
"side imports displacements and exports the traction it needed; the "
"Neumann side imports that traction and exports the displacement it "
"produced. Recover the traction variationally (the interface "
"produced. The traction travels as q = -(sigma . n_own), n_own this "
"side's outward normal, so the outward traction sigma . n_own is -q: "
"the NEGATIVE of the exported numbers and of the qx, qy columns the "
"contract dumps. Recover the traction variationally (the interface "
"reactions of the assembled residual), never by differencing the "
"displacement field — a differenced traction converges one order too "
"slowly and drags the coupled field order down with it.\n\n"
Expand Down
Loading
Loading