diff --git a/docs/exec-plans/active/20260407-formdsl-iga-multipatch-interface-controls.md b/docs/exec-plans/active/20260407-formdsl-iga-multipatch-interface-controls.md index 3d6a8d6..9446b15 100644 --- a/docs/exec-plans/active/20260407-formdsl-iga-multipatch-interface-controls.md +++ b/docs/exec-plans/active/20260407-formdsl-iga-multipatch-interface-controls.md @@ -30,10 +30,10 @@ while preserving global fallback semantics. ## Implementation Checklist - [x] Add OpenSpec proposal/design/spec/tasks artifacts. -- [ ] Implement descriptor and validation support for optional interface +- [x] Implement descriptor and validation support for optional interface penalties. -- [ ] Implement IGA precedence and metadata exposure for effective penalties. -- [ ] Add regression tests and docs updates. +- [x] Implement IGA precedence and metadata exposure for effective penalties. +- [x] Add regression tests and docs updates. ## Risks / Open Questions diff --git a/docs/formdsl-parity-benchmarks.md b/docs/formdsl-parity-benchmarks.md index a5e8caf..45bbe46 100644 --- a/docs/formdsl-parity-benchmarks.md +++ b/docs/formdsl-parity-benchmarks.md @@ -19,7 +19,8 @@ Common options: - `--manifest-path ` for machine-readable output - `--allow-fail` to return zero regardless of tolerance outcome - `--include-multipatch-stress` to run deterministic IGA multipatch stress - fixtures (3-patch mixed orientations + patch-local selector overrides) + fixtures (3-patch mixed orientations + patch-local selector overrides + + per-interface penalty controls) ## What Is Reported diff --git a/docs/ufl-backend-support.md b/docs/ufl-backend-support.md index 45366dc..1d826ed 100644 --- a/docs/ufl-backend-support.md +++ b/docs/ufl-backend-support.md @@ -57,6 +57,10 @@ penalty-style interface coupling matrix contributions. Optional metadata key `multipatch_penalty` may be provided as a finite positive scalar (defaults to `1.0`). +Each interface descriptor may also include optional `penalty` (finite positive +float). When present, interface `penalty` overrides global `multipatch_penalty` +for that interface only. + If multiple interfaces would otherwise resolve to the same selector pair, provide patch-local selector overrides via metadata keys `multipatch_boundary::` mapping to one of diff --git a/openspec/changes/formdsl-iga-multipatch-interface-controls/tasks.md b/openspec/changes/formdsl-iga-multipatch-interface-controls/tasks.md index c1edd9a..34d9e72 100644 --- a/openspec/changes/formdsl-iga-multipatch-interface-controls/tasks.md +++ b/openspec/changes/formdsl-iga-multipatch-interface-controls/tasks.md @@ -5,17 +5,17 @@ ## 2. Descriptor + Validation -- [ ] 2.1 Add optional per-interface penalty field to multipatch interface +- [x] 2.1 Add optional per-interface penalty field to multipatch interface descriptors in IR/parser. -- [ ] 2.2 Add deterministic validation for malformed per-interface penalties. +- [x] 2.2 Add deterministic validation for malformed per-interface penalties. ## 3. IGA Coupling Integration -- [ ] 3.1 Apply per-interface penalty override precedence in IGA interface +- [x] 3.1 Apply per-interface penalty override precedence in IGA interface coupling assembly. -- [ ] 3.2 Expose effective interface penalty values in lowering metadata. +- [x] 3.2 Expose effective interface penalty values in lowering metadata. ## 4. Validation + Docs -- [ ] 4.1 Add regression tests for precedence behavior and malformed values. -- [ ] 4.2 Update docs and execution-plan progress. +- [x] 4.1 Add regression tests for precedence behavior and malformed values. +- [x] 4.2 Update docs and execution-plan progress. diff --git a/src/cutkit/evals/formdsl_benchmarks.py b/src/cutkit/evals/formdsl_benchmarks.py index b766a40..d05e963 100644 --- a/src/cutkit/evals/formdsl_benchmarks.py +++ b/src/cutkit/evals/formdsl_benchmarks.py @@ -313,6 +313,7 @@ def multipatch_stress_fixtures() -> tuple[FormDslMultipatchStressFixture, ...]: "plus_boundary": "marker:b-right", "minus_boundary": "marker:a-top", "orientation": "aligned", + "penalty": 0.75, }, { "plus_patch": "patch-c", @@ -320,6 +321,7 @@ def multipatch_stress_fixtures() -> tuple[FormDslMultipatchStressFixture, ...]: "plus_boundary": "marker:c-top", "minus_boundary": "marker:a-right", "orientation": "reversed", + "penalty": 1.5, }, ], }, @@ -351,6 +353,7 @@ def multipatch_stress_fixtures() -> tuple[FormDslMultipatchStressFixture, ...]: "plus_boundary": "marker:b-right", "minus_boundary": "marker:a-top", "orientation": "aligned", + "penalty": 0.75, }, { "plus_patch": "patch-c", @@ -358,6 +361,7 @@ def multipatch_stress_fixtures() -> tuple[FormDslMultipatchStressFixture, ...]: "plus_boundary": "marker:c-top", "minus_boundary": "marker:a-right", "orientation": "reversed", + "penalty": 1.5, }, ], }, diff --git a/src/cutkit/formdsl/adapter.py b/src/cutkit/formdsl/adapter.py index efdcb3e..8807198 100644 --- a/src/cutkit/formdsl/adapter.py +++ b/src/cutkit/formdsl/adapter.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from math import isfinite from numbers import Integral as IntegralNumber from typing import Any @@ -76,16 +77,59 @@ def _normalize_interface_boundary(raw_boundary: object, *, context: str) -> str: def _multipatch_interface_sort_key( descriptor: MultipatchInterfaceDescriptor, -) -> tuple[str, str, str, str, str]: +) -> tuple[str, str, str, str, str, bool, float]: + penalty = descriptor.penalty if descriptor.penalty is not None else 0.0 return ( descriptor.plus_patch, descriptor.minus_patch, descriptor.plus_boundary, descriptor.minus_boundary, descriptor.orientation, + descriptor.penalty is None, + penalty, ) +def _canonical_interface_descriptor_key( + descriptor: MultipatchInterfaceDescriptor, +) -> tuple[str, str, str, str, str]: + if descriptor.plus_patch <= descriptor.minus_patch: + return ( + descriptor.plus_patch, + descriptor.minus_patch, + descriptor.plus_boundary, + descriptor.minus_boundary, + descriptor.orientation, + ) + return ( + descriptor.minus_patch, + descriptor.plus_patch, + descriptor.minus_boundary, + descriptor.plus_boundary, + descriptor.orientation, + ) + + +def _normalize_interface_penalty(raw_penalty: object, *, context: str) -> float | None: + if raw_penalty is None: + return None + if isinstance(raw_penalty, bool): + raise ValueError(f"{context} penalty must be finite positive float") + if isinstance(raw_penalty, (int, float, str)): + try: + penalty = float(raw_penalty) + except ValueError as exc: + raise ValueError( + f"{context} penalty must be finite positive float" + ) from exc + else: + raise ValueError(f"{context} penalty must be finite positive float") + + if not isfinite(penalty) or penalty <= 0.0: + raise ValueError(f"{context} penalty must be finite positive float") + return penalty + + def _parse_multipatch_descriptor( raw_descriptor: object, *, @@ -168,6 +212,10 @@ def _parse_multipatch_descriptor( raise ValueError( f"{context} multipatch interfaces[{index}] orientation {orientation!r} is unsupported" ) + penalty = _normalize_interface_penalty( + raw_interface.get("penalty"), + context=f"{context} multipatch interfaces[{index}]", + ) interfaces.append( MultipatchInterfaceDescriptor( @@ -176,6 +224,7 @@ def _parse_multipatch_descriptor( plus_boundary=plus_boundary, minus_boundary=minus_boundary, orientation=orientation, + penalty=penalty, ) ) @@ -214,6 +263,7 @@ def _validate_multipatch_descriptor( raise ValueError(f"{context} interfaces must be non-empty") patch_id_set = set(canonical_patch_ids) + canonical_interface_keys: set[tuple[str, str, str, str, str]] = set() for index, interface in enumerate(multipatch.interfaces): if interface.plus_patch == interface.minus_patch: raise ValueError( @@ -238,6 +288,22 @@ def _validate_multipatch_descriptor( raise ValueError( f"{context} interfaces[{index}] orientation {interface.orientation!r} is unsupported" ) + if interface.penalty is not None and not isinstance( + interface.penalty, (int, float) + ): + raise ValueError( + f"{context} interfaces[{index}] penalty must be finite positive float" + ) + _normalize_interface_penalty( + interface.penalty, + context=f"{context} interfaces[{index}]", + ) + interface_key = _canonical_interface_descriptor_key(interface) + if interface_key in canonical_interface_keys: + raise ValueError( + f"{context} interfaces contain duplicate canonical descriptors" + ) + canonical_interface_keys.add(interface_key) canonical_interfaces = tuple( sorted(multipatch.interfaces, key=_multipatch_interface_sort_key) diff --git a/src/cutkit/formdsl/iga_backend.py b/src/cutkit/formdsl/iga_backend.py index 69687ac..6aa1e65 100644 --- a/src/cutkit/formdsl/iga_backend.py +++ b/src/cutkit/formdsl/iga_backend.py @@ -37,6 +37,7 @@ class IGAInterfaceLowering: minus_boundary: str orientation: str orientation_sign: int + coupling_penalty: float = 1.0 def _term_scalar(form_ir: WeakFormIR, kind: str) -> float: @@ -88,6 +89,8 @@ def _lower_multipatch_interfaces( if multipatch is None: return () + default_penalty = _parse_multipatch_penalty(form_ir.metadata) + lowered: list[tuple[tuple[str, str, str, str, str], IGAInterfaceLowering]] = [] for interface in multipatch.interfaces: key = _interface_canonical_key(interface) @@ -101,6 +104,11 @@ def _lower_multipatch_interfaces( minus_boundary=interface.minus_boundary, orientation=interface.orientation, orientation_sign=_orientation_sign(interface.orientation), + coupling_penalty=( + interface.penalty + if interface.penalty is not None + else default_penalty + ), ), ) ) @@ -498,7 +506,7 @@ def _paired_interface_segments( def _add_multipatch_interface_coupling( matrix_rows: list[dict[int, float]], *, - form_ir: WeakFormIR, + metadata: dict[str, str], panel: TrimmedPanel2D, resolution: int, spline_degree: int, @@ -514,7 +522,6 @@ def _add_multipatch_interface_coupling( if not interface_lowering: return - penalty = _parse_multipatch_penalty(form_ir.metadata) nodes_1d, weights_1d = pg.gauss_legendre_01( max(quadrature_order, spline_degree + 1) ) @@ -524,12 +531,12 @@ def _add_multipatch_interface_coupling( plus_selector = _resolve_interface_selector( patch_id=interface.plus_patch, boundary=interface.plus_boundary, - metadata=form_ir.metadata, + metadata=metadata, ) minus_selector = _resolve_interface_selector( patch_id=interface.minus_patch, boundary=interface.minus_boundary, - metadata=form_ir.metadata, + metadata=metadata, ) selector_pair = (plus_selector, minus_selector) if selector_pair in used_selector_pairs: @@ -589,7 +596,9 @@ def _add_multipatch_interface_coupling( nurbs_weights=nurbs_weights, ) - interface_weight = penalty * weight_1d * segment_length + interface_weight = ( + interface.coupling_penalty * weight_1d * segment_length + ) for row_index, row_value, _row_gx, _row_gy in plus_terms: row = matrix_rows[row_index] for col_index, col_value, _col_gx, _col_gy in plus_terms: @@ -824,7 +833,7 @@ def assemble_iga( _add_multipatch_interface_coupling( matrix_rows, - form_ir=form_ir, + metadata=form_ir.metadata, panel=panel, resolution=resolution, spline_degree=spline_degree, diff --git a/src/cutkit/formdsl/ir.py b/src/cutkit/formdsl/ir.py index c8d5e1d..ac6d0da 100644 --- a/src/cutkit/formdsl/ir.py +++ b/src/cutkit/formdsl/ir.py @@ -38,6 +38,7 @@ class MultipatchInterfaceDescriptor: plus_boundary: str minus_boundary: str orientation: str + penalty: float | None = None @dataclass(frozen=True) diff --git a/tests/formdsl/test_formdsl_assembly.py b/tests/formdsl/test_formdsl_assembly.py index 96205e0..62e1a7d 100644 --- a/tests/formdsl/test_formdsl_assembly.py +++ b/tests/formdsl/test_formdsl_assembly.py @@ -559,6 +559,7 @@ def test_parse_form_accepts_multipatch_descriptor_payload() -> None: "plus_boundary": "marker:plus-edge", "minus_boundary": "left", "orientation": "Aligned", + "penalty": "1.75", } ], }, @@ -575,6 +576,7 @@ def test_parse_form_accepts_multipatch_descriptor_payload() -> None: plus_boundary="marker:plus-edge", minus_boundary="left", orientation="aligned", + penalty=1.75, ), ) @@ -600,6 +602,60 @@ def test_parse_form_rejects_multipatch_descriptor_missing_required_key() -> None ) +def test_parse_form_rejects_invalid_multipatch_interface_penalty() -> None: + with pytest.raises(ValueError, match="penalty must be finite positive float"): + parse_form( + { + "terms": [{"kind": "diffusion", "coefficient": 1.0}], + "multipatch": { + "patch_ids": ["patch-a", "patch-b"], + "interfaces": [ + { + "plus_patch": "patch-a", + "minus_patch": "patch-b", + "plus_boundary": "right", + "minus_boundary": "left", + "orientation": "aligned", + "penalty": 0.0, + } + ], + }, + }, + backend="iga", + ) + + +def test_parse_form_rejects_duplicate_canonical_interface_with_penalties() -> None: + with pytest.raises(ValueError, match="duplicate canonical descriptors"): + parse_form( + { + "terms": [{"kind": "diffusion", "coefficient": 1.0}], + "multipatch": { + "patch_ids": ["patch-a", "patch-b"], + "interfaces": [ + { + "plus_patch": "patch-b", + "minus_patch": "patch-a", + "plus_boundary": "right", + "minus_boundary": "left", + "orientation": "aligned", + "penalty": 0.5, + }, + { + "plus_patch": "patch-a", + "minus_patch": "patch-b", + "plus_boundary": "left", + "minus_boundary": "right", + "orientation": "aligned", + "penalty": 2.0, + }, + ], + }, + }, + backend="iga", + ) + + def test_parse_form_weakformir_rejects_unsorted_multipatch_patch_ids() -> None: form_ir = WeakFormIR( trial_space="P1", @@ -623,6 +679,30 @@ def test_parse_form_weakformir_rejects_unsorted_multipatch_patch_ids() -> None: parse_form(form_ir, backend="iga") +def test_parse_form_weakformir_rejects_string_interface_penalty() -> None: + form_ir = WeakFormIR( + trial_space="P1", + test_space="P1", + terms=(Term(kind="diffusion", coefficient=1.0),), + multipatch=MultipatchDescriptor( + patch_ids=("patch-a", "patch-b"), + interfaces=( + MultipatchInterfaceDescriptor( + plus_patch="patch-b", + minus_patch="patch-a", + plus_boundary="right", + minus_boundary="left", + orientation="aligned", + penalty=cast(float, "2.0"), + ), + ), + ), + ) + + with pytest.raises(ValueError, match="penalty must be finite positive float"): + parse_form(form_ir, backend="iga") + + def test_capability_check_strict_vs_permissive() -> None: panel = awb2d.build_section_6_1_1_bspline_panel(sample_count=128) unsupported: dict[str, object] = { @@ -1038,10 +1118,175 @@ def test_iga_accepts_multipatch_interface_descriptor() -> None: minus_boundary="top", orientation="aligned", orientation_sign=1, + coupling_penalty=1.0, ), ) +def test_iga_multipatch_interface_penalty_overrides_global_default() -> None: + panel = awb2d.build_section_6_1_1_bspline_panel(sample_count=128) + override_form = _base_form() + override_form["boundary_conditions"] = [] + override_form["metadata"] = { + "multipatch_penalty": "1.0", + "multipatch_boundary:patch-b:marker:b-right": "right", + "multipatch_boundary:patch-a:marker:a-top": "top", + } + override_form["multipatch"] = { + "patch_ids": ["patch-a", "patch-b"], + "interfaces": [ + { + "plus_patch": "patch-b", + "minus_patch": "patch-a", + "plus_boundary": "marker:b-right", + "minus_boundary": "marker:a-top", + "orientation": "aligned", + "penalty": 2.0, + } + ], + } + + fallback_form = _base_form() + fallback_form["boundary_conditions"] = [] + fallback_form["metadata"] = { + "multipatch_penalty": "2.0", + "multipatch_boundary:patch-b:marker:b-right": "right", + "multipatch_boundary:patch-a:marker:a-top": "top", + } + fallback_form["multipatch"] = { + "patch_ids": ["patch-a", "patch-b"], + "interfaces": [ + { + "plus_patch": "patch-b", + "minus_patch": "patch-a", + "plus_boundary": "marker:b-right", + "minus_boundary": "marker:a-top", + "orientation": "aligned", + } + ], + } + + override_result = assemble_form( + override_form, + backend="iga", + panel=panel, + resolution=2, + spline_degree=1, + quadrature_order=2, + ) + fallback_result = assemble_form( + fallback_form, + backend="iga", + panel=panel, + resolution=2, + spline_degree=1, + quadrature_order=2, + ) + + override_payload = cast(IGAAssemblyResult, override_result.payload) + fallback_payload = cast(IGAAssemblyResult, fallback_result.payload) + assert override_payload.interface_lowering[0].coupling_penalty == 2.0 + assert ( + _sparse_max_abs_diff( + override_payload.matrix_rows, + fallback_payload.matrix_rows, + ) + == 0.0 + ) + + +def test_iga_multipatch_per_interface_penalties_change_operator_entries() -> None: + panel = awb2d.build_section_6_1_1_bspline_panel(sample_count=128) + uniform_form = _base_form() + uniform_form["boundary_conditions"] = [] + uniform_form["metadata"] = { + "multipatch_penalty": "1.0", + "multipatch_boundary:patch-b:marker:b-right": "right", + "multipatch_boundary:patch-a:marker:a-top": "top", + "multipatch_boundary:patch-c:marker:c-top": "top", + "multipatch_boundary:patch-a:marker:a-right": "right", + } + uniform_form["multipatch"] = { + "patch_ids": ["patch-a", "patch-b", "patch-c"], + "interfaces": [ + { + "plus_patch": "patch-b", + "minus_patch": "patch-a", + "plus_boundary": "marker:b-right", + "minus_boundary": "marker:a-top", + "orientation": "aligned", + }, + { + "plus_patch": "patch-c", + "minus_patch": "patch-a", + "plus_boundary": "marker:c-top", + "minus_boundary": "marker:a-right", + "orientation": "reversed", + }, + ], + } + + per_interface_form = _base_form() + per_interface_form["boundary_conditions"] = [] + per_interface_form["metadata"] = dict( + cast(dict[str, str], uniform_form["metadata"]) + ) + per_interface_form["multipatch"] = { + "patch_ids": ["patch-a", "patch-b", "patch-c"], + "interfaces": [ + { + "plus_patch": "patch-b", + "minus_patch": "patch-a", + "plus_boundary": "marker:b-right", + "minus_boundary": "marker:a-top", + "orientation": "aligned", + "penalty": 0.5, + }, + { + "plus_patch": "patch-c", + "minus_patch": "patch-a", + "plus_boundary": "marker:c-top", + "minus_boundary": "marker:a-right", + "orientation": "reversed", + "penalty": 2.0, + }, + ], + } + + uniform_result = assemble_form( + uniform_form, + backend="iga", + panel=panel, + resolution=2, + spline_degree=1, + quadrature_order=2, + ) + per_interface_result = assemble_form( + per_interface_form, + backend="iga", + panel=panel, + resolution=2, + spline_degree=1, + quadrature_order=2, + ) + + uniform_payload = cast(IGAAssemblyResult, uniform_result.payload) + per_interface_payload = cast(IGAAssemblyResult, per_interface_result.payload) + assert [ + entry.coupling_penalty for entry in per_interface_payload.interface_lowering + ] == [ + 0.5, + 2.0, + ] + assert ( + _sparse_max_abs_diff( + uniform_payload.matrix_rows, + per_interface_payload.matrix_rows, + ) + > 0.0 + ) + + def test_iga_multipatch_interface_descriptor_changes_operator_entries() -> None: panel = awb2d.build_section_6_1_1_bspline_panel(sample_count=128) base_form = _base_form()