diff --git a/examples/final/EXEMPLE_SPEC_FINALE_MULTIPHYSIQUE_CORE.py b/examples/final/EXEMPLE_SPEC_FINALE_MULTIPHYSIQUE_CORE.py index 89e64f59c..b9022f2b6 100644 --- a/examples/final/EXEMPLE_SPEC_FINALE_MULTIPHYSIQUE_CORE.py +++ b/examples/final/EXEMPLE_SPEC_FINALE_MULTIPHYSIQUE_CORE.py @@ -15,6 +15,7 @@ import numpy as np import pops +from pops.diagnostics import Integral from pops.fields import ( CellCenteredSecondOrder, ConstantNullspace, @@ -115,6 +116,9 @@ class RuntimeSnapshot: states: dict[str, np.ndarray] fields: dict[str, np.ndarray] histories: dict[str, tuple[np.ndarray, ...]] + bind_identity: str + layout_plan_identity: str + layout_identities: tuple[str, ...] program_hash: str consumer_graph_identity: str consumer_cursors: dict[str, Any] @@ -129,6 +133,7 @@ class ExecutionEvidence: checkpoint_path: Path hdf5_identity: str paraview_identity: str + missing_mapping_refusal: str accepted: RuntimeSnapshot restored: RuntimeSnapshot continuous: RuntimeSnapshot @@ -348,6 +353,36 @@ def build_authoring(*, output_mode: Any = None) -> MultiphysicsAuthoring: if output_mode is None: output_mode = ParallelMode.SERIAL + end_schedule = on_end(clock=program.clock) + # The field RHS is -ne + ni, so these owner-qualified density integrals publish + # the two signed charge contributions with the same exact coefficients. + # Momentum is likewise selected by typed physical role, never by component name. + end_diagnostics = ( + Integral( + block=electron_block, + role=Density(), + cadence=end_schedule, + coefficient=-1.0, + ), + Integral( + block=ion_block, + role=Density(), + cadence=end_schedule, + coefficient=1.0, + ), + Integral( + block=electron_block, + role=Momentum(axis=x_axis), + cadence=end_schedule, + ), + Integral( + block=electron_block, + role=Momentum(axis=y_axis), + cadence=end_schedule, + ), + Integral(block=ion_block, role=Momentum(axis=x_axis), cadence=end_schedule), + Integral(block=ion_block, role=Momentum(axis=y_axis), cadence=end_schedule), + ) case.consumers(ConsumerGraph.from_consumers(( ScientificOutput( format=ParaView(mode=output_mode), @@ -357,8 +392,9 @@ def build_authoring(*, output_mode: Any = None) -> MultiphysicsAuthoring: ), ScientificOutput( format=HDF5(mode=output_mode), - schedule=on_end(clock=program.clock), + schedule=end_schedule, fields=(electron_state, ion_state), + diagnostics=end_diagnostics, target="state/two_fluid", ), Checkpoint( @@ -419,6 +455,79 @@ def build_final_case( return FinalMultiphysicsCase(authoring, plan, layout, provider) +def require_missing_mapping_provider_refusal( + *, cells: int = DEFAULT_CELLS, publication_root: Any = None, +) -> str: + """Prove that a cross-layout ion-to-field read cannot resolve without its provider.""" + + if isinstance(cells, bool) or not isinstance(cells, int) or cells < 4: + raise ValueError("cells must be an integer >= 4") + root = None if publication_root is None else Path(publication_root) + if root is not None and root.exists() and any( + path.is_file() for path in root.rglob("*") + ): + raise ValueError("the missing-mapping refusal root must not contain prior artifacts") + from pops.layouts import Uniform + from pops.mesh import ( + CartesianGrid, + LayoutMappingOperation, + LayoutPlanBuilder, + LayoutRepresentation, + LayoutSynchronization, + PeriodicAxes, + ) + + authoring = build_authoring() + pops.validate(authoring.case) + subjects = authoring.case.layout_subjects() + blocks = {block.local_id: block for block in subjects.blocks} + states = {state.block_ref.local_id: state for state in subjects.states} + frame = authoring.model.frame + + def descriptor() -> Any: + return Uniform(CartesianGrid( + frame=frame, + cells=(cells, cells), + periodic=PeriodicAxes(frame.axes), + )) + + builder = LayoutPlanBuilder(authoring.case.owner_path.canonical()) + electron_layout = builder.layout("electrons", descriptor()) + ion_layout = builder.layout("ions", descriptor()) + builder.assign_block(blocks["electrons"], electron_layout) + builder.assign_state(states["electrons"], electron_layout) + builder.assign_block(blocks["ions"], ion_layout) + builder.assign_state(states["ions"], ion_layout) + (field_subject,) = subjects.fields + builder.assign_field(field_subject, electron_layout) + builder.require_mapping( + ion_layout, + electron_layout, + source=states["ions"], + target=field_subject, + operation=LayoutMappingOperation.CONSERVATIVE_CELL_AVERAGE_V1, + synchronization=LayoutSynchronization.BEFORE_STEP_V1, + source_representation=LayoutRepresentation.CELL_AVERAGE_V1, + target_representation=LayoutRepresentation.CELL_AVERAGE_V1, + ) + try: + builder.resolve(**subjects.to_dict()) + except ValueError as error: + reason = str(error) + if "missing mapping provider" not in reason: + raise RuntimeError( + "the invalid multiphysics layout failed outside provider resolution" + ) from error + if root is not None and root.exists() and any( + path.is_file() for path in root.rglob("*") + ): + raise RuntimeError( + "missing mapping/provider refusal published an artifact" + ) from error + return reason + raise RuntimeError("a cross-layout multiphysics plan resolved without a mapping provider") + + def build_initial_state(*, cells: int = DEFAULT_CELLS) -> dict[str, np.ndarray]: """Create positive, neutral two-fluid data without selecting any resolved semantics.""" @@ -478,12 +587,20 @@ def _snapshot(simulation: Any) -> RuntimeSnapshot: ) for name in simulation.history_names() } + bound = simulation.bound_snapshot.to_dict() + layout_plan = bound["layout"] return RuntimeSnapshot( time=float(simulation.time()), macro_step=int(simulation.macro_step()), states=states, fields=fields, histories=histories, + bind_identity=simulation.bind_identity.token, + layout_plan_identity=str(layout_plan["qualified_id"]), + layout_identities=tuple( + str(layout["handle"]["qualified_id"]) + for layout in layout_plan["layouts"] + ), program_hash=str(simulation.installed_program_hash()), consumer_graph_identity=simulation.consumer_graph.identity.token, consumer_cursors=simulation.consumer_cursors.to_data(), @@ -496,6 +613,10 @@ def _require_same_snapshot(left: RuntimeSnapshot, right: RuntimeSnapshot, *, whe scalar_pairs = { "time": (left.time, right.time), "macro_step": (left.macro_step, right.macro_step), + "bind_identity": (left.bind_identity, right.bind_identity), + "layout_plan_identity": ( + left.layout_plan_identity, right.layout_plan_identity), + "layout_identities": (left.layout_identities, right.layout_identities), "program_hash": (left.program_hash, right.program_hash), "consumer_graph_identity": ( left.consumer_graph_identity, right.consumer_graph_identity), @@ -528,6 +649,10 @@ def run_and_restart( from pops.output import HDF5, ParaView root = Path(output_dir) + refusal_root = root / "refused_missing_mapping" + missing_mapping_refusal = require_missing_mapping_provider_refusal( + cells=cells, publication_root=refusal_root, + ) root.mkdir(parents=True, exist_ok=True) _target, artifact = compile_final_case(cells=cells) simulation = _bind_artifact( @@ -583,6 +708,7 @@ def run_and_restart( checkpoint_path=checkpoint_path, hdf5_identity=hdf5.output_identity.token, paraview_identity=paraview.output_identity.token, + missing_mapping_refusal=missing_mapping_refusal, accepted=accepted, restored=restored, continuous=continuous, @@ -605,6 +731,8 @@ def main() -> None: print(" HDF5: %s" % evidence.hdf5_identity) print(" ParaView: %s" % evidence.paraview_identity) print(" checkpoint: %s" % evidence.checkpoint_path) + print(" layout: %s" % evidence.restarted.layout_plan_identity) + print(" missing mapping refusal: %s" % evidence.missing_mapping_refusal) print(" bit-identical restart: step %d" % evidence.restarted.macro_step) diff --git a/examples/final/README.md b/examples/final/README.md index 1b097d9b3..cc5459192 100644 --- a/examples/final/README.md +++ b/examples/final/README.md @@ -18,8 +18,10 @@ matching contract note is [`EXEMPLE_SPEC_FINALE_MULTIPHYSIQUE_CORE.py`](EXEMPLE_SPEC_FINALE_MULTIPHYSIQUE_CORE.py) selects two state spaces of one model into two owner-qualified blocks, couples them through a typed -elliptic field on the same periodic layout, and proves scientific outputs plus bit-identical restart -continuation through the public lifecycle. +elliptic field on the same periodic layout, publishes owner-qualified signed charge-contribution +and momentum diagnostics, refuses a required cross-layout read when no mapping provider is +installed, and proves scientific outputs plus bind/layout-exact restart continuation through the +public lifecycle. ## Public contract diff --git a/python/pops/diagnostics/measures.py b/python/pops/diagnostics/measures.py index 138fbffa2..f13dea24e 100644 --- a/python/pops/diagnostics/measures.py +++ b/python/pops/diagnostics/measures.py @@ -52,14 +52,21 @@ def _role_name(value: Any) -> str | None: ) from exc -def _operation(name: str, reduction: str, *, transform: str = "identity", - metric_weighted: bool = False) -> dict[str, Any]: +def _operation( + name: str, + reduction: str, + *, + transform: str = "identity", + metric_weighted: bool = False, + coefficient: float = 1.0, +) -> dict[str, Any]: """Build one callback-free native scalar-reduction instruction.""" return { "name": name, "reduction": reduction, "transform": transform, "metric_weighted": metric_weighted, + "coefficient": coefficient.hex(), } @@ -214,7 +221,7 @@ def diagnostic_execution(self) -> dict[str, Any]: if kind is None: raise ValueError("typed norm descriptor has no canonical kind") return { - "schema_version": 1, + "schema_version": 2, "role": _role_name(self.role), "operations": [operations[kind]], "conservation": None, @@ -250,7 +257,7 @@ def options(self) -> dict: def diagnostic_execution(self) -> dict[str, Any]: return { - "schema_version": 1, + "schema_version": 2, "role": None, "operations": [ _operation("step_change_l2", "step_change_l2"), @@ -263,19 +270,52 @@ class Integral(_Measure): """A typed domain-integral reduction over a block: ``Integral(role=Density())``. Sums the (role-selected) quantity over the block volume; ``mass`` is - ``Integral(role=Density())``. Lowers to the native ``integral`` reduction. + ``Integral(role=Density())``. ``coefficient`` applies one exact finite scalar after the + collective reduction, so signed contributions such as charge remain owner-qualified without + copying or transforming fields in Python. Lowers to the native ``integral`` reduction. """ category = "diagnostic_integral" scheme = "integral" reduction = "sum" + def __init__( + self, + block: Any = None, + role: Any = None, + cadence: Any = None, + *, + coefficient: float = 1.0, + ) -> None: + super().__init__(block=block, role=role, cadence=cadence) + if isinstance(coefficient, bool) or not isinstance(coefficient, (int, float)): + raise TypeError("Integral coefficient must be a finite real number") + try: + normalized = float(coefficient) + except OverflowError as exc: + raise ValueError("Integral coefficient must be finite") from exc + if not math.isfinite(normalized): + raise ValueError("Integral coefficient must be finite") + if normalized == 0.0: + raise ValueError("Integral coefficient must be nonzero") + self.coefficient = normalized + + def options(self) -> dict: + options = super().options() + options["coefficient"] = self.coefficient.hex() + return options + def diagnostic_execution(self) -> dict[str, Any]: return { - "schema_version": 1, + "schema_version": 2, "role": _role_name(self.role), "operations": [ - _operation("integral", "sum", metric_weighted=True), + _operation( + "integral", + "sum", + metric_weighted=True, + coefficient=self.coefficient, + ), ], "conservation": None, } @@ -294,7 +334,7 @@ class MinMax(_Measure): def diagnostic_execution(self) -> dict[str, Any]: return { - "schema_version": 1, + "schema_version": 2, "role": _role_name(self.role), "operations": [ _operation("min", "min"), @@ -373,7 +413,7 @@ def diagnostic_execution(self) -> dict[str, Any]: raise TypeError( "ConservationCheck quantity must implement diagnostic_execution()") plan = provider() - if type(plan) is not dict or plan.get("schema_version") != 1: + if type(plan) is not dict or plan.get("schema_version") != 2: raise TypeError("ConservationCheck quantity returned an invalid execution plan") operations = plan.get("operations") if not isinstance(operations, list) or len(operations) != 1: @@ -381,7 +421,7 @@ def diagnostic_execution(self) -> dict[str, Any]: "ConservationCheck requires one scalar diagnostic quantity; " "a multi-valued MinMax check is ambiguous") return { - "schema_version": 1, + "schema_version": 2, "role": plan.get("role"), "operations": [dict(operations[0])], "conservation": {"tolerance": self.tolerance.hex()}, diff --git a/python/pops/output/_consumer_contracts.py b/python/pops/output/_consumer_contracts.py index cfe4260a5..c488af657 100644 --- a/python/pops/output/_consumer_contracts.py +++ b/python/pops/output/_consumer_contracts.py @@ -55,6 +55,29 @@ def _nonnegative_binary64_hex(value: Any, where: str) -> str: return number.hex() +def _finite_binary64_hex(value: Any, where: str) -> str: + """Normalize a signed finite binary64 value for identity-bearing manifests.""" + if isinstance(value, bool): + raise TypeError("%s must be a finite number" % where) + if isinstance(value, str): + try: + number = float.fromhex(value) + except (OverflowError, ValueError) as exc: + raise TypeError("%s must be a canonical float.hex() string" % where) from exc + if number.hex() != value: + raise ValueError("%s must be a canonical float.hex() string" % where) + elif isinstance(value, (int, float)): + try: + number = float(value) + except OverflowError as exc: + raise ValueError("%s must be a finite number" % where) from exc + else: + raise TypeError("%s must be a finite number" % where) + if not math.isfinite(number): + raise ValueError("%s must be a finite number" % where) + return number.hex() + + def _exact_handle(value: Any, kind: str | None, where: str) -> Handle: if not isinstance(value, Handle) or not value.is_resolved: raise TypeError("%s must be a canonical Handle" % where) @@ -309,8 +332,8 @@ def _diagnostic_execution(value: Any) -> Mapping[str, Any]: if not isinstance(value, Mapping) or set(value) != { "schema_version", "role", "operations", "conservation"}: raise TypeError("DiagnosticQuantity.execution has an unknown schema") - if value["schema_version"] != 1: - raise ValueError("DiagnosticQuantity.execution schema_version must be 1") + if value["schema_version"] != 2: + raise ValueError("DiagnosticQuantity.execution schema_version must be 2") role = value["role"] if role is not None: _text(role, "DiagnosticQuantity.execution.role") @@ -321,7 +344,7 @@ def _diagnostic_execution(value: Any) -> Mapping[str, Any]: for index, operation in enumerate(operations): where = "DiagnosticQuantity.execution.operations[%d]" % index if not isinstance(operation, Mapping) or set(operation) != { - "name", "reduction", "transform", "metric_weighted"}: + "name", "reduction", "transform", "metric_weighted", "coefficient"}: raise TypeError("%s has an unknown schema" % where) name = _text(operation["name"], "%s.name" % where) reduction = operation["reduction"] @@ -335,11 +358,16 @@ def _diagnostic_execution(value: Any) -> Mapping[str, Any]: raise TypeError("%s.metric_weighted must be an exact bool" % where) if weighted and reduction not in {"sum", "abs_sum", "sum_sq"}: raise ValueError("only additive diagnostic reductions may be metric-weighted") + coefficient = _finite_binary64_hex( + operation["coefficient"], "%s.coefficient" % where) + if float.fromhex(coefficient) == 0.0: + raise ValueError("%s.coefficient must be nonzero" % where) normalized.append({ "name": name, "reduction": reduction, "transform": transform, "metric_weighted": weighted, + "coefficient": coefficient, }) if len({row["name"] for row in normalized}) != len(normalized): raise ValueError("DiagnosticQuantity execution operation names must be unique") @@ -354,7 +382,7 @@ def _diagnostic_execution(value: Any) -> Mapping[str, Any]: raise ValueError("a conservation check requires exactly one scalar operation") normalized_conservation = {"tolerance": tolerance} return freeze_data({ - "schema_version": 1, + "schema_version": 2, "role": role, "operations": normalized, "conservation": normalized_conservation, diff --git a/python/pops/runtime/_runtime_consumers.py b/python/pops/runtime/_runtime_consumers.py index 0d7dbddd1..6cc3b8d5b 100644 --- a/python/pops/runtime/_runtime_consumers.py +++ b/python/pops/runtime/_runtime_consumers.py @@ -2572,6 +2572,26 @@ def _diagnostic_values( value = math.sqrt(value) elif operation["transform"] != "identity": raise ValueError("unknown diagnostic scalar transform") + coefficient_token = operation["coefficient"] + if not isinstance(coefficient_token, str): + raise TypeError( + "diagnostic coefficient must be canonical float.hex() text" + ) + try: + coefficient = float.fromhex(coefficient_token) + except (OverflowError, ValueError) as exc: + raise ValueError( + "diagnostic coefficient is not valid float.hex() text" + ) from exc + if ( + coefficient.hex() != coefficient_token + or not math.isfinite(coefficient) + or coefficient == 0.0 + ): + raise ValueError( + "diagnostic coefficient is not canonical finite nonzero binary64" + ) + value *= coefficient reduction_name = operation["name"] terms: dict[str, float] = {} conservation = execution["conservation"] diff --git a/tests/python/examples/final/test_multiphysics_core_example.py b/tests/python/examples/final/test_multiphysics_core_example.py index 7726e1ab3..5efa680f1 100644 --- a/tests/python/examples/final/test_multiphysics_core_example.py +++ b/tests/python/examples/final/test_multiphysics_core_example.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import replace import importlib.util from pathlib import Path import subprocess @@ -36,7 +37,10 @@ def test_example_script_runs_outputs_and_restart_without_mock_or_fallback(tmp_pa assert completed.returncode == 0, completed.stderr assert "PoPS final multiphysics acceptance:" in completed.stdout + assert "missing mapping refusal: missing mapping provider" in completed.stdout assert "bit-identical restart: step 2" in completed.stdout + refused = output / "refused_missing_mapping" + assert not refused.exists() or not any(path.is_file() for path in refused.rglob("*")) from pops.output import HDF5, ParaView @@ -45,6 +49,48 @@ def test_example_script_runs_outputs_and_restart_without_mock_or_fallback(tmp_pa output / "accepted" / "visualization" / "two_fluid").latest assert hdf5.output_identity.token in completed.stdout assert paraview.output_identity.token in completed.stdout + example = _load_example() + target = example.build_final_case( + cells=8, + output_mode=example._native_output_mode(), + ) + import pops + + resolved = pops.resolve( + target.authoring.case, + layout=target.layout_plan, + layout_providers={target.layout_handle: target.layout_provider}, + ) + diagnostic_output = next( + node + for node in resolved.consumer_graph.nodes + if node.target_uri == "state/two_fluid" + ) + quantities = { + quantity.identity.token: quantity + for quantity in diagnostic_output.diagnostic_quantities + } + diagnostic_rows = hdf5.manifest["snapshot"]["diagnostics"] + assert len(diagnostic_rows) == 6 + assert {row["key"]["state_id"] for row in diagnostic_rows} == set(quantities) + from pops.identity import Identity + + for row in diagnostic_rows: + quantity = quantities[row["key"]["state_id"]] + assert row["key"]["reference"] == quantity.reference.canonical_identity() + assert row["key"]["reduction"] == "integral" + assert row["key"]["level"] == 0 + assert Identity.from_token(row["key"]["layout_identity"]).domain == "layout" + block = quantity.reference.block_ref.local_id + role = quantity.execution["role"] + coefficient = quantity.execution["operations"][0]["coefficient"] + expected_coefficient = -1.0 if (block, role) == ("electrons", "Density") else 1.0 + assert coefficient == expected_coefficient.hex() + if role == "Density": + value = float.fromhex(row["value"]) + assert value < 0.0 if block == "electrons" else value > 0.0 + # State-space units intentionally fail closed until PoPS has a typed unit protocol. + assert row["units"] == "unspecified" checkpoint = output / "accepted_restart.npz" assert checkpoint.is_file() @@ -58,6 +104,48 @@ def test_example_script_runs_outputs_and_restart_without_mock_or_fallback(tmp_pa assert "field_provider_slots" in stored +def test_missing_mapping_provider_refuses_before_plan_or_publication(tmp_path) -> None: + example = _load_example() + refused = tmp_path / "refused_missing_mapping" + + reason = example.require_missing_mapping_provider_refusal( + cells=8, publication_root=refused, + ) + + assert "missing mapping provider" in reason + assert not refused.exists() or not any(path.is_file() for path in refused.rglob("*")) + + +@pytest.mark.parametrize( + ("field", "changed"), + ( + ("bind_identity", "pops.bind.v1::changed"), + ("layout_plan_identity", "pops.layout-plan.v1::changed"), + ("layout_identities", ("pops.handle.v1::changed",)), + ), +) +def test_restart_snapshot_refuses_bind_or_layout_identity_drift(field, changed) -> None: + example = _load_example() + snapshot = example.RuntimeSnapshot( + time=0.0, + macro_step=0, + states={"electrons": np.zeros((3, 1, 1))}, + fields={"electrostatic": np.zeros((1, 1))}, + histories={"electrons.electrons": (np.zeros((3, 1, 1)),)}, + bind_identity="pops.bind.v1::accepted", + layout_plan_identity="pops.layout-plan.v1::accepted", + layout_identities=("pops.handle.v1::accepted",), + program_hash="program", + consumer_graph_identity="consumer-graph", + consumer_cursors={}, + ) + + with pytest.raises(RuntimeError, match=field): + example._require_same_snapshot( + snapshot, replace(snapshot, **{field: changed}), where="strict restart", + ) + + def test_program_has_exact_field_context_and_transactional_implicit_join() -> None: core = _load_example().build_authoring() values = tuple(core.program._values) @@ -75,6 +163,16 @@ def test_program_has_exact_field_context_and_transactional_implicit_join() -> No (field_token.inputs[0].block, field_token.inputs[0].id), (field_token.inputs[1].block, field_token.inputs[1].id), ) + collision_token = next( + value for value in values if value.op == "solve_coupled_implicit" + ) + assert collision_token.attrs["operator"] == "implicit_collision" + assert collision_token.attrs["problem_kind"] == "coupled_implicit_euler" + assert collision_token.attrs["method"] == "newton" + assert collision_token.attrs["max_iter"] == 12 + assert tuple( + block.local_id for block in collision_token.attrs["blocks"] + ) == ("electrons", "ions") solve_actions = { value.inputs[0].op: value.attrs["action"].kind for value in values if value.op == "solve_outcome" @@ -145,10 +243,85 @@ def test_case_resolves_explicit_layout_consumers_and_two_provider_field() -> Non assert resolved.consumer_graph.is_resolved assert sorted(node.kind.value for node in resolved.consumer_graph.nodes) == [ "checkpoint", "scientific_output", "scientific_output"] + diagnostic_output = next( + node + for node in resolved.consumer_graph.nodes + if node.target_uri == "state/two_fluid" + ) + assert len(diagnostic_output.diagnostics) == 6 + assert len(diagnostic_output.diagnostic_quantities) == 6 + expected_diagnostics = { + ("electrons", "Density"), + ("electrons", "MomentumX"), + ("electrons", "MomentumY"), + ("ions", "Density"), + ("ions", "MomentumX"), + ("ions", "MomentumY"), + } + actual_diagnostics = { + ( + quantity.reference.block_ref.local_id, + quantity.execution["role"], + ) + for quantity in diagnostic_output.diagnostic_quantities + } + assert actual_diagnostics == expected_diagnostics + assert { + quantity.layout_id + for quantity in diagnostic_output.diagnostic_quantities + } == {target.layout_handle.qualified_id} + assert all( + quantity.levels == (0,) + and quantity.execution["operations"] == ( + { + "name": "integral", + "reduction": "sum", + "transform": "identity", + "metric_weighted": True, + "coefficient": ( + -1.0 + if quantity.reference.block_ref.local_id == "electrons" + and quantity.execution["role"] == "Density" + else 1.0 + ).hex(), + }, + ) + for quantity in diagnostic_output.diagnostic_quantities + ) provider_pack = resolved.field_plans["electrostatic"].native_options["provider_pack"] assert [row["owner_block"] for row in provider_pack] == ["electrons", "ions"] assert [row["key"] for row in provider_pack] == ["electron_charge", "ion_charge"] field_plan = resolved.field_plans["electrostatic"] + native_options = field_plan.native_options + assert native_options["rhs"] == "composite" + assert native_options["method"] == { + "native_method": "cell_centered_second_order", + "order": 2, + "ghost_depth": 1, + } + assert native_options["bc"] == "explicit" + solver_provider = native_options["solver_provider"] + assert solver_provider["provider"]["provider_id"] == "pops.field-solver.geometric-mg" + assert { + face["type"] + for face in solver_provider["facts"]["boundary"]["faces"] + } == {"periodic"} + nullspace_provider = native_options["nullspace_provider"] + assert ( + nullspace_provider["provider"]["provider_id"] + == "pops.field-nullspace.constant" + ) + assert nullspace_provider["resolution"]["singular"] is True + assert ( + nullspace_provider["resolution"]["native_contract"]["options"]["gauge.value"] + == 0.0 + ) + equation = field_plan.operator.inspect()["physics"]["equation"]["equation"] + assert ( + equation["lhs"]["field_expression"]["type"] + == "pops._ir.expr.Laplacian" + ) + assert equation["rhs"]["protocol"] == "pops.expr.dag.v1" output_route = field_plan.native_options["output_route"] assert output_route["owner_block"] == "electrons" assert output_route["key"] == "electrostatic" diff --git a/tests/python/unit/runtime/test_consumer_authoring.py b/tests/python/unit/runtime/test_consumer_authoring.py index 85d6cf50f..52eae493f 100644 --- a/tests/python/unit/runtime/test_consumer_authoring.py +++ b/tests/python/unit/runtime/test_consumer_authoring.py @@ -96,6 +96,7 @@ def test_direct_consumers_resolve_references_layout_levels_and_parallel_mode(): "reduction": "sum", "transform": "identity", "metric_weighted": True, + "coefficient": (1.0).hex(), }, ) assert checkpoint.output_format is None @@ -248,6 +249,7 @@ def test_console_monitor_is_a_scheduled_rank_zero_diagnostic_consumer(): "reduction": "step_change_l2", "transform": "identity", "metric_weighted": False, + "coefficient": (1.0).hex(), }, ) diff --git a/tests/python/unit/runtime/test_diagnostics_typed.py b/tests/python/unit/runtime/test_diagnostics_typed.py index 289b8dc56..d4eec139f 100644 --- a/tests/python/unit/runtime/test_diagnostics_typed.py +++ b/tests/python/unit/runtime/test_diagnostics_typed.py @@ -83,6 +83,7 @@ def test_step_change_norm_is_typed_l2_and_whole_state(): assert change.diagnostic_execution()["operations"] == [{ "name": "step_change_l2", "reduction": "step_change_l2", "transform": "identity", "metric_weighted": False, + "coefficient": (1.0).hex(), }] with pytest.raises(ValueError, match="exactly.*L2"): StepChangeNorm(L1()) @@ -92,13 +93,36 @@ def test_step_change_norm_is_typed_l2_and_whole_state(): # --- Integral / MinMax ------------------------------------------------------------------ def test_integral_is_a_sum_reduction(): - mass = Integral(role=Density()) + mass = Integral(role=Density(), coefficient=-2.0) assert isinstance(mass, Descriptor) assert mass.category == "diagnostic_integral" assert mass.options()["scheme"] == "integral" assert mass.options()["role"] == "Density" assert mass.options()["block"] is None + assert mass.options()["coefficient"] == (-2.0).hex() assert mass.capabilities().to_dict()["reduction"] == "sum" + assert mass.diagnostic_execution()["operations"][0]["coefficient"] == (-2.0).hex() + + +@pytest.mark.parametrize("coefficient", [True, "1", object()]) +def test_integral_rejects_untyped_coefficients(coefficient): + with pytest.raises(TypeError, match="coefficient"): + Integral(coefficient=coefficient) + + +@pytest.mark.parametrize( + "coefficient", + [ + 0.0, + float("inf"), + float("-inf"), + float("nan"), + pytest.param(10**10_000, id="overflowing-int"), + ], +) +def test_integral_rejects_nonfinite_or_zero_coefficients(coefficient): + with pytest.raises(ValueError, match="coefficient"): + Integral(coefficient=coefficient) def test_minmax_is_a_minmax_reduction(): @@ -181,7 +205,7 @@ def test_measures_expose_closed_native_execution_plans(): } assert plans["l1"]["operations"] == [{ "name": "l1", "reduction": "abs_sum", "transform": "identity", - "metric_weighted": True, + "metric_weighted": True, "coefficient": (1.0).hex(), }] assert plans["l2"]["operations"][0]["transform"] == "sqrt" assert plans["linf"]["operations"][0]["reduction"] == "abs_max"