diff --git a/CHANGELOG.md b/CHANGELOG.md index bf4607d0b..4e791dc8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,11 +18,38 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning ### Changed +- Type-erased variable-recovery reports now retain the selected and last-attempted method kinds. + Runtime failures name the actual recovery route instead of exposing only a plan-local integer, + while rejected outcomes keep the selected method explicitly unknown. +- Capability reports now distinguish the delivered typed Riemann rejection path from an unavailable + prepared recovery policy. Rusanov, HLL, HLLC, and Roe advertise their common device-copyable + `FluxEvaluation` and transactional rejection, while ordered fallback chains, + requested-versus-used solver diagnostics, counters, and restart metadata fail closed instead of + being inferred from the selected solver. +- Variable-recovery capability reports now separate the delivered prepared closed-form consumers + from the complete ADC-755 deletion gate. System materialization and every production face route + advertise typed publication control, while the remaining source, AMR-transfer, boundary, + inverse-conversion, cache/restart, backend, and performance families fail closed as an unavailable + complete-consumer cutover. +- ADC-749 carries exact periodic face identifications through the model-aware hyperbolic boundary + plan. Uniform scalar layouts execute mapped periodic halos (including cross-axis maps); mapped + vector/axial component transforms and AMR mapped periodic fill-patch/regrid remain explicit + fail-closed capabilities until their model-aware component and hierarchy contracts are available. +- ADC-749 now makes numerical resolution the fail-closed acceptance boundary for built-in transport + descriptors. Characteristic closures without a prepared eigenstructure, forged representation + converters, unsupported analytic dependencies, and mixed logical clocks can no longer survive as + inert metadata and fail only during compile or bind. +- Boundary provider identities now retain an immutable typed law such as inflow, ghost formula, + directional transport, or no-flux. Resolution no longer has to infer semantics from a handle name + or output port, while the still-missing post-Riemann execution ABI remains explicitly unavailable. - Strict AMR checkpoint payload v7 now persists the accepted shared-interface flux audit together with Program clocks, histories, tagging state, conservative ledger and synchronization report. Restart validates every fragment's topology epoch, level pair, exact clock window, resolved rational stage weight, geometry and duration before publishing the image; rejected restart or Program attempts leave the previous accepted report byte-exact. +- Generated physical-flux bricks now make their qualified provider requirements executable native + ABI evidence: the binder validates every row at compile time and reads only its declared storage + slots instead of scanning the model's complete auxiliary width. - AMR checkpoint capability reports now distinguish same-rank bit-identical replay from non-bit-identical rank-count rematerialization with Dense persisted histories. The explicit `RegridOnRestart()` policy now restores and authenticates the recorded accepted state before one diff --git a/docs/ALGORITHMS.md b/docs/ALGORITHMS.md index f8e298ef3..a8ac1c78f 100644 --- a/docs/ALGORITHMS.md +++ b/docs/ALGORITHMS.md @@ -145,6 +145,17 @@ global auxiliary slot, or provider outside its resolved pack. It returns `FluxDe applied exactly once by the spatial layer. A fallible evaluation maps explicitly to retry, reject or abort transaction actions. +Primitive face reconstruction is a fallible numerical operation, not an unchecked model callback. +Every conservative-to-primitive stencil sample is evaluated through +`PreparedVariableRecovery` and returned as a `ReconstructedFaceState` carrying both the candidate +and its `RecoveryReport`. Cartesian, cached-HLL, masked, polar and embedded-boundary kernels consume +that report before calling the numerical flux. A refused candidate therefore writes only finite +transactional scratch, joins the same device/MPI failure reduction as a fallible flux, and cannot be +published. The type-erased report preserves the selected and last-attempted method kinds in addition +to their chain indices; diagnostics can therefore name the actual closed-form, nonlinear, bracketed, +repair, or custom route without reconstructing policy from an erased plan. The pointwise route is +fixed-size, `POPS_HD`, allocation-free and callback-free. + **Constraints / remarks.** CFL condition: $\Delta t \le C\,\dfrac{\min(\Delta x,\Delta y)}{\max|\lambda|}$, where $\lambda$ is the local wave speed and $C \le 1$ at order 1; `max_wave_speed_mf` provides $\max|\lambda|$. A model without transport ($\max|\lambda| = 0$) does not constrain the step @@ -220,10 +231,17 @@ requires `m.wave_speeds`). `HLLCFlux` requires `HasHLLCStructure` (`pressure`, ` `hllc_star_state`) and `RoeFlux` requires `HasRoeDissipation` (`roe_dissipation`). Euler conforms through those same capabilities. A missing capability is rejected during route resolution; there is no component-count inference and no implicit HLL/Rusanov substitution. The -compatibility function `rusanov_flux` (in `spatial_operator.hpp`) delegates to `RusanovFlux{}` for serial -references. The flux is passed by template: `compute_face_fluxes` and -`assemble_rhs` are templated on the flux policy, chosen -independently of the limiter. The `SourceFreeModel` adapter (explicit IMEX half-step) forwards +four built-ins return the common device-copyable `FluxEvaluation`. Built-in rejection reasons use +the typed `RiemannFailureCause` vocabulary before device/MPI reduction. In particular, Roe rejects +a non-finite dissipation or final candidate flux, while HLLC attributes non-finite physical flux, +pressure, contact speed, star state, and final candidate flux separately. Neither policy publishes a +successful NaN result; the runtime rolls the owning step transaction back without selecting another +solver. +The compatibility function `rusanov_flux` (in `spatial_operator.hpp`) delegates to +`RusanovFlux{}` for serial references. The flux is passed by template: +`compute_face_fluxes` and +`assemble_rhs` are templated on the flux policy, chosen independently +of the limiter. The `SourceFreeModel` adapter (explicit IMEX half-step) forwards `pressure`, `wave_speeds`, and the optional HLLC/Roe structural hooks only when the wrapped model exposes them (`requires` clauses), so the explicit half-step keeps the selected Riemann provider. A moment hierarchy (no fluid roles, no primitive `p`) can also @@ -369,8 +387,8 @@ function weno5z(vm2, vm1, v0, vp1, vp2): # face entre v0 et vp1 **Code.** Pointwise `Limiter` policies in [`include/pops/numerics/fv/reconstruction.hpp`](../include/pops/numerics/fv/reconstruction.hpp): `NoSlope` -(`n_ghost = 1`, `operator()` returns `Real(0)`), `Minmod` and `VanLeer` (`n_ghost = 2`, `operator()(a,b)` -returns the limited slope, absolute value coded by hand to stay device-safe without ``), `Weno5` +(`n_ghost = 1`, piecewise-constant face value), `Minmod`, `VanLeer`, `MC` and `Superbee` +(`n_ghost = 2`, `limited_slope(a,b)` returns the limited slope with device-safe scalar arithmetic), `Weno5` (`n_ghost = 3`, a tag whose `operator()` is a no-op that just satisfies the `Limiter` concept). The order-5 reconstruction lives in the free function `weno5z(vm2, vm1, v0, vp1, vp2)` of the same header: it returns the value at the face between `v0` and `vp1`, and for the opposite face one passes it the @@ -378,12 +396,18 @@ reversed stencil. All are `POPS_HD` (device-callable, static polymorphism: the l parameter of `assemble_rhs` / `compute_face_fluxes`, inlined on device). The mesh stencil access and the routing by `n_ghost` are in `reconstruct` of `numerics/spatial_operator.hpp`; the policy itself loops over no grid. The reconstruction can act on the conserved or primitive variables -(`rho, u, p`) depending on the block. +(`rho, u, p`) depending on the block. Production kernels use the typed +`reconstruct_recovered`/`reconstruct_pp_recovered` entry points and consume their `RecoveryReport` +before any face flux; the value-only wrappers remain low-level compatibility helpers. **Constraints / remarks.** The reconstruction does not change the hyperbolic stability condition: the step stays bounded by the CFL of section 1, `dt <= C dx / max|lambda|`. Limits and pitfalls: - `Minmod` is strictly TVD but falls back to local order 1 at extrema (it erases smooth peaks); for the Diocotron growth modes one prefers `VanLeer`, less dissipative at extrema. +- `MC` uses $\operatorname{minmod}((a+b)/2,2a,2b)$ and is a less diffusive TVD compromise; + `Superbee` uses $\operatorname{maxmod}(\operatorname{minmod}(2a,b), + \operatorname{minmod}(a,2b))$ and is the most compressive builtin MUSCL limiter. Their + implementations avoid overflowing intermediate doubled slopes for finite inputs. - `weno5z` is smooth (no branch on the sign: the $\beta_k$ and $\tau_5$ are squares so always $\ge 0$, and only $|\beta_0-\beta_2|$ goes through a ternary), which makes it fully device-callable; the floor `eps = 1e-40` avoids division by zero on a constant stencil. @@ -391,10 +415,29 @@ step stays bounded by the CFL of section 1, `dt <= C dx / max|lambda|`. Limits a (the reconstructed states can leave the admissible domain on the conserved side). - The ghost cost drives the halo width to exchange: 1 (NoSlope), 2 (MUSCL), 3 (WENO5). +For a Python-authored model, finite primitive recovery can be strengthened with explicit physical +constraints after declaring the primitive layout: + +```python +# after model.primitive_state(rho, u, v, p, conservative=(...)) +model.recovery_admissibility(rho=rho > 0, p=p > 0) +``` + +Each keyword identifies the primitive component reported on failure; its value is a symbolic Boolean +expression over the primitive state. Code generation emits a device-callable +`recovery_admissible(Prim, failing_component)` method. `CompositeModel` forwards that optional +contract and `prepare_model_variable_recovery` installs it in the same ordered recovery plan as the +conversion method. A finite candidate that violates a predicate is therefore not published: the +chain proceeds to its next declared method, or finishes with `inadmissible_candidate` when no method +remains. Models that declare no policy retain the finite-only path and emit no extra method. + **Validation.** `test_weno_convergence` (the face reconstruction of a smooth function reaches order 5), `test_primitive_recon` (conserved <-> primitive conversions and their use in the reconstruction), `test_spatial_discretisation` (the reconstruction x numerical flux pair is a named type, exercised end -to end). +to end), and `test_weno_convergence` (MC/Superbee reference formulas, symmetry, homogeneity, TVD +bounds and finite extreme inputs in addition to WENO convergence), and +`test_variable_recovery_chain` (a model-declared physical predicate blocks publication +and preserves the typed failing component). --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4d0a27e6d..b75178e60 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -813,6 +813,11 @@ model-qualified `FaceTrace` values plus `FaceContext` and returns a typed densit `SpatialOperator` alone applies face and cell measures. Provider packs are selected from exact `(owner, space kind, space name, component)` identities. Missing, unavailable or contract-mismatched providers fail during selection; homonymous components from different owners never alias. +Generated physical models carry those qualified rows as `flux_provider_requirements`. The native +binder validates their count, qualification, availability, unique in-range storage slots and then +loads only those declared slots into the model-qualified device pack. Hand-written C++ test models +that do not declare this generated ABI retain the full-width fixture path; generated PoPS models +never use that route. ## Limitations diff --git a/docs/design/external-component-packages.md b/docs/design/external-component-packages.md index caed1db16..fdf18cfde 100644 --- a/docs/design/external-component-packages.md +++ b/docs/design/external-component-packages.md @@ -57,8 +57,9 @@ are reused only when their bytes authenticate to the same binary identity. table layouts, plus the version of the common request/value ABI. The complete declaration feeds the catalog digest. The generator emits `pops.interfaces`, the Python route data and `generated_component_abi.hpp` together; `--check` makes any hand-edited drift fail CI. The current -protocol includes separate tables for numerical flux, ghost boundary, field-boundary closure, -tagging, clustering, transfer, reflux, field solve, writer and field topology. Adding an +protocol includes separate tables for numerical flux, ghost boundary, post-Riemann boundary-flux +transformation, field-boundary closure, tagging, clustering, transfer, reflux, field solve, writer +and field topology. Adding an implementation requires no central scientific switch. The installed CPU route proves 2D, `float64`, host execution. It supports source/header payloads and diff --git a/docs/design/native-capability-matrix.md b/docs/design/native-capability-matrix.md index baed22819..d9843723d 100644 --- a/docs/design/native-capability-matrix.md +++ b/docs/design/native-capability-matrix.md @@ -126,7 +126,58 @@ Supported native routes include: interpolation are cell-centered on the supplied route. Derived fields use `elliptic_solve` and caches use `patch_topology`; unsupported provider contracts fail before artifact creation. - Finite-volume spatial discretisation on the 2D core. +- One prepared, model-aware 2D transport-boundary plan shared by Uniform and AMR native/compiled + routes. The capability matrix marks this route `partial` and names its exact built-ins: + periodicity, extrapolation, constant or `RuntimeParam` fixed state, conservative device-side + analytic fixed state over typed `(x,y,t,params)`, fixed-state primitive inflow converted once + through the exact compiled block-model `to_conservative` provider, and typed-role slip wall. + Analytic programs are immutable postfix tables evaluated in native device kernels at the exact + `BoundaryEvaluationPoint`; no Python callback or hot-loop allocation is retained. The analytic + finite-value contract is strictly non-mutating: one device preflight and one communicator + reduction complete before any same-level, periodic, MPI or physical halo write. The commit kernel + then evaluates the program again; this deliberate two-pass route avoids a per-cell scratch field + but retains one blocking collective per analytic boundary fill. + The analytic route remains `partial`: primitive per-point conversion and discrete state/field/input reads are + rejected, as is an analytic ghost depth larger than the normal domain extent. Analytic faces with + axis-permuted periodic coordinates also fail closed until a prepared coordinate map exists. The + conversion route is explicitly `partial`: conservative-to-primitive recovery and arbitrary + representation components remain unavailable, and conversion does not invent a boundary + admissibility projection. A separate `unavailable` row exposes the missing characteristic + no-inflow kernel. Post-Riemann transformation is instead an explicit `partial` route: a typed + `BoundaryFlux` component receives the already evaluated outward-normal flux and executes between + the Riemann solve and divergence/reflux through the same prepared Uniform/AMR plan. The runtime + converts lower and upper faces to outward orientation before the call and converts the result + back to canonical positive-axis face storage afterwards. This route is currently 2D Cartesian + host-batch execution; it has no device-native or embedded/cut-cell metric ABI, and the ordinary + Uniform route materializes face fields when selected. + These requests fail during resolution or lowering; none silently degrades to component-wise + ghost filling. A native rank-1/2/4 regrid fixture removes and recreates the fine hierarchy, then + proves that uncovered internal fine ghosts retain the conservative coarse-fine transfer and are + never treated as physical faces by the rematerialized prepared boundary session. The explicit + public route is + `Inflow(state=U, value=primitive_values, representation=Primitive(), + converter=pops.boundary.model_primitive_to_conservative(U))`; the converter is derived from the + authenticated block state and cannot name an unrelated callback or kernel. + `primitive_values` follows the model's declared primitive-variable order. - Native Riemann routes: Rusanov, HLL, HLLC, Roe, subject to model capability requirements. + `riemann:typed_failure_outcome` is deliberately `partial`: every built-in returns the common + device-copyable `FluxEvaluation` with typed status, stability bound, and reason code, and a + reduced failure rejects the owning transaction instead of publishing a candidate or silently + selecting another solver. `riemann:prepared_recovery_policy` is separately `unavailable`: + there is no prepared ordered solver chain, requested-versus-used solver outcome, block counter, + or restart metadata yet. Callers can therefore request the single-solver typed-rejection route + explicitly, but cannot claim a configured fallback policy. +- Prepared variable recovery is explicitly `partial`. One block-prepared closed-form method returns + a device-copyable `RecoveryOutcome`/`RecoveryReport`. Type erasure retains both the selected and + last-attempted method kinds, so a successful fallback or a refusal cannot be reported as an opaque + chain index. System conservative-to-primitive + materialization and Cartesian, polar, masked, and embedded-boundary face reconstruction consume + publication permission before copying a candidate or evaluating a flux. This route adds no + implicit repair, fallback, or mutable cache. The separate + `recovery:complete_consumer_cutover` capability remains `unavailable`: initial/analytic and + model/source conversion, AMR transfer/regrid, primitive boundary traces, fallible + primitive-to-conservative conversion, persistent warm starts, cache/restart, backend parity, and + performance evidence do not yet share that authority. - Native reconstruction routes: first-order, MUSCL, WENO5/WENO5-Z. - Elliptic GeometricMG on Uniform/AMR and FFT on uniform periodic constant-coefficient grids. - Matrix-free Krylov descriptors: CG, BiCGStab, GMRES, Richardson. @@ -180,10 +231,12 @@ Supported native routes include: artifact creation until their adapter contract owns the same persistent-state route. MPI capture validates the rank-independent accepted-state image on every producer before sealing or publication; disagreement fails collectively and cannot leave a partial checkpoint. +- The prepared limiter registry exposes native `Minmod`, `VanLeer`, `MC`, and `Superbee` MUSCL + policies. Each is a stateless `POPS_HD` compile-time provider with formal order 2 and exactly two + ghost layers; Uniform, AMR, MPI and supported device targets consume the same route identity. Explicit unsupported rows include: -- `limiter:mc` and `limiter:superbee`: catalogued descriptors with no native C++ symbol. - `elliptic:fft_amr`: FFT requires a single uniform periodic mesh; AMR uses GeometricMG. - `checkpoint:parallel_hdf5`: parallel HDF5 is a scientific-output route, not a restartable checkpoint encoding; `RuntimeInstance.checkpoint()` and the typed `Checkpoint` consumer use uniform v5 or AMR diff --git a/docs/design/temporal-execution-contract.md b/docs/design/temporal-execution-contract.md index 6e25499c8..ac83e90c9 100644 --- a/docs/design/temporal-execution-contract.md +++ b/docs/design/temporal-execution-contract.md @@ -61,6 +61,34 @@ program schedule with the installed program before native state mutation and req checkpointed step strategy for the next attempt. Schema v1 and other historical payloads require an offline migration; runtime restart contains no compatibility branch. +## Cell-local temporal-partition restart foundation + +The AMR Program accepted image now has an explicit temporal-partition section. Its cell-local form +stores a prepared-provider identity, hierarchy topology epoch, integer synchronization tick and tick +denominator, plus canonically ordered `(level, cell, rung, accepted_tick)` records. Floating-point +cell clocks and rank-local addresses are not checkpoint authority. Every persisted cell must be at +the same rung-aligned synchronization tick; a provisional attempt cannot be serialized. + +`BatchedCellTemporalPartition` supplies the execution-provider-independent transaction semantics: +one attempt target, ordered same-rung batches, synchronization barriers, commit, rollback, strict +restore and an accepted-state manifest. The AMR restart path decodes and validates this state before +replacing accepted bytes. A malformed provider identity or topology/level mismatch leaves the +previous image untouched. +The public Program report consumes the same native image and exposes provider identity, accepted +tick, denominator, cell count and per-rung counts. + +For this bounded slice, a cell-local checkpoint restarts only with the recorded MPI cardinality and +`RestoreRecordedHierarchy`. Rank-change and `RegridOnRestart` are rejected during Python preflight, +before the native restart transaction, because rematerializing canonical cell ids onto a new owner +or topology is not implemented yet. + +This foundation deliberately does not pretend that the existing global AMR driver is cell-local +stepping. If a cell-local image reaches that driver, execution fails before the Program body instead +of silently falling back to a global `dt`. ADC-707/ADC-708 still own the prepared patch/task graph; +ADC-756 still requires Kokkos rung batches, actual local-stage boundary evaluation, time-integrated +same-level/MPI/coarse-fine flux ledgers, regrid/rank-change rematerialization, device determinism and +performance evidence. None of those execution or conservation claims is made by this restart slice. + Offline envelope inspection authenticates only the integrity of a canonical checkpoint; it is not a migration. The frozen release-v2 Uniform checkpoint predates the envelope and omits lifecycle identities, temporal state, consumer cursors, and field-provider state. The explicit diff --git a/docs/tuto/scalar_advection/README.md b/docs/tuto/scalar_advection/README.md index 19951b227..981f4f3b8 100644 --- a/docs/tuto/scalar_advection/README.md +++ b/docs/tuto/scalar_advection/README.md @@ -187,14 +187,16 @@ Les substitutions suivantes utilisent toutes des briques natives : reconstruction.FirstOrder() reconstruction.MUSCL(limiters.Minmod()) reconstruction.MUSCL(limiters.VanLeer()) +reconstruction.MUSCL(limiters.MC()) +reconstruction.MUSCL(limiters.Superbee()) reconstruction.WENO5() # implementation native WENO5-Z ``` -Le document source cite aussi les limiteurs MC et Superbee. Leurs fonctions usuelles sont +Les limiteurs MC et Superbee utilisent respectivement $\phi_{MC}(r)=\max(0,\min(2r,(1+r)/2,2))$ et -$\phi_{SB}(r)=\max(0,\min(2r,1),\min(r,2))$. PoPS 1.0.0 ne fournit pas encore de descriptor -natif pour ces deux limiteurs. Ils peuvent etre compares sur le papier, mais ne sont pas -selectionnables dans ce tutoriel. +$\phi_{SB}(r)=\max(0,\min(2r,1),\min(r,2))$. Ils passent par le meme registre prepare que Minmod +et VanLeer, demandent exactement deux couches de cellules fantomes et sont selectionnables sans +branche specifique Uniform, AMR, MPI ou backend. ## Tutoriel 1 : briques preimplementees diff --git a/include/pops/core/model/physical_model.hpp b/include/pops/core/model/physical_model.hpp index d6b201c42..eece21d5c 100644 --- a/include/pops/core/model/physical_model.hpp +++ b/include/pops/core/model/physical_model.hpp @@ -171,6 +171,19 @@ concept HasPrimitiveVars = { m.to_conservative(p) } -> std::same_as; }; +/// OPTIONAL physical admissibility contract for conservative-to-primitive recovery. +/// +/// The conversion formula and the admissibility policy are deliberately separate: a finite +/// primitive candidate may still be physically invalid (for example non-positive density or +/// pressure). When present, the prepared recovery service invokes this device-callable predicate +/// before publication. `failing_component` identifies the primitive component whose declared +/// constraint failed; implementations set it to -1 on success. +template +concept HasRecoveryAdmissibility = + HasPrimitiveVars && requires(const M m, const typename M::Prim p, int* failing_component) { + { m.recovery_admissible(p, failing_component) } -> std::same_as; + }; + /// Hyperbolic brick of a model: flux + wave speed + variables + cons<->prim conversions. /// /// Variables, conversions and flux are physically LINKED (a flux is written for a given layout diff --git a/include/pops/core/state/variables.hpp b/include/pops/core/state/variables.hpp index 43c9e2512..f4e43ba60 100644 --- a/include/pops/core/state/variables.hpp +++ b/include/pops/core/state/variables.hpp @@ -36,7 +36,11 @@ enum class VariableRole { Pressure, Temperature, Scalar, - Custom + Custom, + // Append new canonical roles so the numeric values of the established role ABI stay stable. + AxialX, + AxialY, + AxialZ }; /// Forward declaration: VariableSet::index_of(const std::string&) resolves a canonical role NAME via @@ -120,6 +124,12 @@ inline const char* role_name(VariableRole r) { return "scalar"; case VariableRole::Custom: return "custom"; + case VariableRole::AxialX: + return "axial_x"; + case VariableRole::AxialY: + return "axial_y"; + case VariableRole::AxialZ: + return "axial_z"; } return "custom"; } @@ -150,6 +160,12 @@ inline VariableRole role_from_name(const std::string& s) { return VariableRole::Temperature; if (s == "scalar") return VariableRole::Scalar; + if (s == "axial_x") + return VariableRole::AxialX; + if (s == "axial_y") + return VariableRole::AxialY; + if (s == "axial_z") + return VariableRole::AxialZ; return VariableRole::Custom; } diff --git a/include/pops/mesh/boundary/boundary_component_executor.hpp b/include/pops/mesh/boundary/boundary_component_executor.hpp index f926dd45b..8d0a18cd2 100644 --- a/include/pops/mesh/boundary/boundary_component_executor.hpp +++ b/include/pops/mesh/boundary/boundary_component_executor.hpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -539,6 +540,187 @@ inline void apply_ghost_component(const PreparedGhostBoundaryComponent::Session& scatter_field(state, workspace.locations, workspace.ghosts); } +struct PreparedBoundaryFluxWorkspace { + int axis = 0; + int side = 1; + int state_components = 0; + std::vector face_locations; + std::vector cell_locations; + std::vector base_outward_flux; + std::vector transformed_outward_flux; + std::vector xy; + std::vector outward_normals; + std::vector face_measures; + std::vector packed_dependencies; + std::vector dependencies; + std::vector parameters; + std::vector actions; +}; + +inline Box2D boundary_flux_face_box(Box2D cells, int axis) { + if (axis < 0 || axis >= 2) + throw std::invalid_argument("boundary flux face axis is invalid"); + ++cells.hi[axis]; + return cells; +} + +inline PreparedBoundaryFluxWorkspace prepare_boundary_flux_workspace( + const PreparedBoundaryFluxComponent::Session& component, const MultiFab& prototype, + const BoundaryFieldRegistry& registry, const Geometry& geometry) { + const auto& spec = component.spec(); + if (spec.region.kind != POPS_BOUNDARY_FACE_V1 || spec.region.codimension != 1 || + spec.region.axes.size() != 1 || spec.region.sides.size() != 1) + throw std::invalid_argument("post-Riemann boundary flux requires one exact oriented face"); + PreparedBoundaryFluxWorkspace workspace; + workspace.axis = spec.region.axes.front(); + workspace.side = spec.region.sides.front(); + workspace.state_components = prototype.ncomp(); + if (workspace.axis < 0 || workspace.axis >= 2 || (workspace.side != -1 && workspace.side != 1)) + throw std::invalid_argument("post-Riemann boundary flux face is invalid"); + const int normal_face = workspace.side < 0 ? geometry.domain.lo[workspace.axis] + : geometry.domain.hi[workspace.axis] + 1; + const int normal_cell = + workspace.side < 0 ? geometry.domain.lo[workspace.axis] : geometry.domain.hi[workspace.axis]; + for (int local = 0; local < prototype.local_size(); ++local) { + const Box2D valid = prototype.box(local); + const Box2D faces = boundary_flux_face_box(valid, workspace.axis); + if (normal_face < faces.lo[workspace.axis] || normal_face > faces.hi[workspace.axis]) + continue; + const int tangent = 1 - workspace.axis; + const int lower = std::max(valid.lo[tangent], geometry.domain.lo[tangent]); + const int upper = std::min(valid.hi[tangent], geometry.domain.hi[tangent]); + for (int coordinate = lower; coordinate <= upper; ++coordinate) { + const int face_i = workspace.axis == 0 ? normal_face : coordinate; + const int face_j = workspace.axis == 1 ? normal_face : coordinate; + const int cell_i = workspace.axis == 0 ? normal_cell : coordinate; + const int cell_j = workspace.axis == 1 ? normal_cell : coordinate; + workspace.face_locations.push_back({local, face_i, face_j}); + workspace.cell_locations.push_back({local, cell_i, cell_j}); + } + } + const std::size_t count = workspace.face_locations.size(); + const std::size_t components = static_cast(prototype.ncomp()); + workspace.base_outward_flux.resize(count * components); + workspace.transformed_outward_flux.resize(count * components); + workspace.xy.resize(count * 2u); + workspace.outward_normals.assign(count * 2u, 0.0); + workspace.face_measures.assign( + count, static_cast(workspace.axis == 0 ? geometry.dy() : geometry.dx())); + workspace.actions.resize(count, POPS_COMPONENT_CONTINUE_V1); + for (std::size_t point = 0; point < count; ++point) { + const auto& face = workspace.face_locations[point]; + workspace.xy[2u * point] = + workspace.axis == 0 ? static_cast(workspace.side < 0 ? geometry.xlo : geometry.xhi) + : static_cast(geometry.x_cell(face.i)); + workspace.xy[2u * point + 1u] = + workspace.axis == 1 ? static_cast(workspace.side < 0 ? geometry.ylo : geometry.yhi) + : static_cast(geometry.y_cell(face.j)); + workspace.outward_normals[2u * point + static_cast(workspace.axis)] = + static_cast(workspace.side); + } + workspace.packed_dependencies.reserve(spec.states.size() + spec.fields.size()); + for (const std::string& identity : spec.states) + workspace.packed_dependencies.push_back( + prepare_const_field(BoundaryConstRole::State, registry.state_index(identity), identity, + registry, count, spec.layout_identity, spec.region.identity)); + for (const std::string& identity : spec.fields) + workspace.packed_dependencies.push_back( + prepare_const_field(BoundaryConstRole::Field, registry.field_index(identity), identity, + registry, count, spec.layout_identity, spec.region.identity)); + workspace.dependencies.reserve(workspace.packed_dependencies.size()); + for (const auto& row : workspace.packed_dependencies) + workspace.dependencies.push_back(row.view); + workspace.parameters = scalar_table(spec); + return workspace; +} + +inline void apply_boundary_flux_component( + const PreparedBoundaryFluxComponent::Session& component, + PreparedBoundaryFluxWorkspace& workspace, const MultiFab& state, + const BoundaryFieldRegistry& registry, const Geometry& geometry, MultiFab& fx, MultiFab& fy, + const runtime::multiblock::BoundaryEvaluationPoint& point) { + if (state.ncomp() != workspace.state_components || fx.ncomp() != state.ncomp() || + fy.ncomp() != state.ncomp() || fx.local_size() != state.local_size() || + fy.local_size() != state.local_size()) + throw std::runtime_error( + "post-Riemann boundary flux layout changed after executor preparation"); + if (workspace.face_locations.empty()) + return; + const auto& spec = component.spec(); + MultiFab& face_flux = workspace.axis == 0 ? fx : fy; + for (int local = 0; local < state.local_size(); ++local) { + const Box2D expected = boundary_flux_face_box(state.box(local), workspace.axis); + if (face_flux.box(local) != expected) + throw std::runtime_error("post-Riemann boundary flux differs from the prepared face layout"); + } + const std::size_t count = workspace.face_locations.size(); + const std::size_t components = static_cast(state.ncomp()); + for (std::size_t index = 0; index < count; ++index) { + const auto& location = workspace.face_locations[index]; + const ConstArray4 values = face_flux.fab(location.local_fab).const_array(); + for (std::size_t component_index = 0; component_index < components; ++component_index) { + const double outward = + static_cast(workspace.side) * + static_cast(values(location.i, location.j, static_cast(component_index))); + workspace.base_outward_flux[index * components + component_index] = outward; + workspace.transformed_outward_flux[index * components + component_index] = outward; + } + } + for (auto& row : workspace.packed_dependencies) + pack_field_into(bound_const(registry, row.role, row.slot), workspace.cell_locations, + geometry.domain, false, state, row.values); + std::fill(workspace.actions.begin(), workspace.actions.end(), POPS_COMPONENT_CONTINUE_V1); + PopsConstFieldViewV1 base_view = const_view(workspace.base_outward_flux, count, components, + spec.layout_identity, spec.region.identity); + base_view.centering = POPS_FIELD_CENTERING_FACE_V1; + base_view.centering_axes = 1u << static_cast(workspace.axis); + const PopsConstFieldViewV1 coordinate_view = + const_view(workspace.xy, count, 2, spec.layout_identity, spec.region.identity); + const PopsConstFieldViewV1 normal_view = + const_view(workspace.outward_normals, count, 2, spec.layout_identity, spec.region.identity); + PopsBoundaryFluxRequestV1 request{sizeof(PopsBoundaryFluxRequestV1), + spec.target_identity.c_str(), + spec.state_identity.c_str(), + base_view, + coordinate_view, + normal_view, + workspace.face_measures.data(), + spec.region.view(), + workspace.dependencies.size(), + workspace.dependencies.data(), + workspace.parameters.size(), + workspace.parameters.data(), + logical_time(point), + component.execution().view()}; + PopsFieldViewV1 transformed_view = + field_view(workspace.transformed_outward_flux, count, components, spec.layout_identity, + spec.region.identity); + transformed_view.centering = POPS_FIELD_CENTERING_FACE_V1; + transformed_view.centering_axes = 1u << static_cast(workspace.axis); + PopsBoundaryFluxResultV1 result{ + sizeof(PopsBoundaryFluxResultV1), + transformed_view, + workspace.actions.data(), + {sizeof(PopsComponentStatusV1), 0, POPS_COMPONENT_CONTINUE_V1, nullptr}}; + const int code = component::transform_boundary_flux(component.boundary_flux_api(), + component.state(), request, result); + PreparedBoundaryFluxComponent::require_success(code, result.status, "transform_faces"); + for (std::size_t index = 0; index < count; ++index) { + if (workspace.actions[index] != POPS_COMPONENT_CONTINUE_V1) + throw std::runtime_error("native BoundaryFlux returned a non-continue per-face action"); + const auto& location = workspace.face_locations[index]; + Array4 values = face_flux.fab(location.local_fab).array(); + for (std::size_t component_index = 0; component_index < components; ++component_index) { + const double outward = + workspace.transformed_outward_flux[index * components + component_index]; + if (!std::isfinite(outward)) + throw std::runtime_error("native BoundaryFlux returned a non-finite flux"); + values(location.i, location.j, static_cast(component_index)) = + static_cast(static_cast(workspace.side) * outward); + } + } +} + struct PreparedFieldBoundaryWorkspace { std::vector locations; std::vector xy; diff --git a/include/pops/mesh/boundary/prepared_boundary_component.hpp b/include/pops/mesh/boundary/prepared_boundary_component.hpp index cdbef5140..676169d64 100644 --- a/include/pops/mesh/boundary/prepared_boundary_component.hpp +++ b/include/pops/mesh/boundary/prepared_boundary_component.hpp @@ -57,7 +57,7 @@ struct PreparedBoundaryComponentSpec { std::shared_ptr execution; }; -enum class PreparedBoundaryOperation { GhostRegion, FieldResidual, FieldJvp }; +enum class PreparedBoundaryOperation { GhostRegion, FluxTransform, FieldResidual, FieldJvp }; /// One statically typed prepared component invocation. The operation is a template argument, never /// a production string branch: installation chooses one typed entry point and scientific calls retain @@ -86,8 +86,15 @@ class PreparedBoundaryComponent final { spec_.interface_version); } + [[nodiscard]] const PopsBoundaryFluxApiV1& boundary_flux_api() const { + static_assert(Operation == PreparedBoundaryOperation::FluxTransform); + return component_->table(POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1, + spec_.interface_version); + } + [[nodiscard]] const PopsFieldBoundaryClosureApiV1& field_api() const { - static_assert(Operation != PreparedBoundaryOperation::GhostRegion); + static_assert(Operation == PreparedBoundaryOperation::FieldResidual || + Operation == PreparedBoundaryOperation::FieldJvp); return component_->table( POPS_NATIVE_INTERFACE_FIELD_BOUNDARY_CLOSURE_V1, spec_.interface_version); } @@ -159,8 +166,15 @@ class PreparedBoundaryComponent final { spec_.interface_version); } + const PopsBoundaryFluxApiV1& boundary_flux_api() const { + static_assert(Operation == PreparedBoundaryOperation::FluxTransform); + return component_->table(POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1, + spec_.interface_version); + } + const PopsFieldBoundaryClosureApiV1& field_api() const { - static_assert(Operation != PreparedBoundaryOperation::GhostRegion); + static_assert(Operation == PreparedBoundaryOperation::FieldResidual || + Operation == PreparedBoundaryOperation::FieldJvp); return component_->table( POPS_NATIVE_INTERFACE_FIELD_BOUNDARY_CLOSURE_V1, spec_.interface_version); } @@ -177,6 +191,8 @@ class PreparedBoundaryComponent final { static constexpr PopsNativeInterfaceIdV1 native_interface_id_() { if constexpr (Operation == PreparedBoundaryOperation::GhostRegion) return POPS_NATIVE_INTERFACE_GHOST_BOUNDARY_V1; + else if constexpr (Operation == PreparedBoundaryOperation::FluxTransform) + return POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1; else return POPS_NATIVE_INTERFACE_FIELD_BOUNDARY_CLOSURE_V1; } @@ -184,6 +200,8 @@ class PreparedBoundaryComponent final { const PopsComponentTableHeaderV1& table_header() const { if constexpr (Operation == PreparedBoundaryOperation::GhostRegion) return ghost_api().header; + else if constexpr (Operation == PreparedBoundaryOperation::FluxTransform) + return boundary_flux_api().header; else return field_api().header; } @@ -202,6 +220,14 @@ class PreparedBoundaryComponent final { component::validate_execution_context(spec_.execution->view()); if constexpr (Operation == PreparedBoundaryOperation::GhostRegion) { component::require_operation(ghost_api().apply_region_batch != nullptr, "apply_region_batch"); + } else if constexpr (Operation == PreparedBoundaryOperation::FluxTransform) { + component::require_operation(boundary_flux_api().transform_faces != nullptr, + "transform_faces"); + if (spec_.region.kind != POPS_BOUNDARY_FACE_V1 || spec_.region.codimension != 1 || + spec_.outputs.size() != 1 || spec_.outputs.front() != spec_.state_identity || + !spec_.directions.empty()) + throw std::invalid_argument( + "BoundaryFlux requires one oriented face, one state output and no direction table"); } else { component::require_operation( Operation == PreparedBoundaryOperation::FieldResidual ? field_api().residual != nullptr @@ -226,6 +252,8 @@ class PreparedBoundaryComponent final { using PreparedGhostBoundaryComponent = PreparedBoundaryComponent; +using PreparedBoundaryFluxComponent = + PreparedBoundaryComponent; using PreparedFieldBoundaryResidualComponent = PreparedBoundaryComponent; using PreparedFieldBoundaryJvpComponent = diff --git a/include/pops/mesh/boundary/prepared_boundary_plan.hpp b/include/pops/mesh/boundary/prepared_boundary_plan.hpp index b6711c619..4e4416401 100644 --- a/include/pops/mesh/boundary/prepared_boundary_plan.hpp +++ b/include/pops/mesh/boundary/prepared_boundary_plan.hpp @@ -1,9 +1,10 @@ /// @file -/// @brief Executable, immutable boundary authority prepared before block construction. +/// @brief Executable boundary authority finalized before numerical execution. /// /// A PreparedBoundaryPlan is the executable native transport-face lowering of one resolved -/// GhostProducerPlan. Resolution and string/Handle dispatch happen once during installation, never -/// in a face-cell loop. The executed order is: +/// GhostProducerPlan. Resolution, one-time model conversion, component preparation and +/// string/Handle dispatch happen before a session is retained, never in a face-cell loop. The +/// executed order is: /// /// same-level/MPI + prepared periodic identifications -> physical faces. /// @@ -17,14 +18,19 @@ #include #include -#include +#include #include #include #include +#include #include +#include +#include #include +#include #include +#include #include #include #include @@ -85,9 +91,104 @@ struct PreparedBoundaryReadDependencies { std::vector fields; }; -/// Native boundary plan captured by every block closure. Component BCs permit systems with -/// different Dirichlet data per conservative component while the topology (periodic vs physical) -/// remains common to the state. +namespace detail { + +inline void append_boundary_request_u64(std::string& payload, std::uint64_t value) { + for (int shift = 56; shift >= 0; shift -= 8) + payload.push_back(static_cast((value >> shift) & UINT64_C(0xff))); +} + +inline void append_boundary_request_size(std::string& payload, std::size_t value) { + if constexpr (sizeof(std::size_t) > sizeof(std::uint64_t)) { + if (value > static_cast(std::numeric_limits::max())) + throw std::length_error("boundary request exceeds canonical uint64 length capacity"); + } + append_boundary_request_u64(payload, static_cast(value)); +} + +inline void append_boundary_request_bytes(std::string& payload, std::string_view value) { + append_boundary_request_size(payload, value.size()); + if (!value.empty()) + payload.append(value.data(), value.size()); +} + +inline void append_boundary_request_int(std::string& payload, int value) { + append_boundary_request_u64(payload, + static_cast(static_cast(value))); +} + +inline void append_boundary_request_strings(std::string& payload, + const std::vector& values) { + append_boundary_request_size(payload, values.size()); + for (const auto& value : values) + append_boundary_request_bytes(payload, value); +} + +inline void append_boundary_request_reals(std::string& payload, const std::vector& values) { + static_assert(sizeof(double) == sizeof(std::uint64_t)); + static_assert(std::numeric_limits::is_iec559, + "boundary request consensus requires IEEE-754 binary64"); + append_boundary_request_size(payload, values.size()); + for (double value : values) + append_boundary_request_u64(payload, std::bit_cast(value)); +} + +/// Exact byte identity of every argument that can affect one prepared boundary plan. Analytic +/// opcode/literal rows are included here as well as fixed-state and topology metadata so a +/// collectively prepared plan cannot diverge through a non-program field. +inline std::string canonical_prepared_boundary_plan_request( + std::string_view name, std::string_view identity, int required_depth, + const std::vector& face_types, const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, + const std::vector& omitted_interface_faces, std::string_view state_identity, + const PreparedBoundaryReadDependencies& read_dependencies, + const std::vector& periodic_identifications, + const std::vector& face_representations, + const std::vector& face_converter_identities, + const std::vector>& face_analytic_opcodes, + const std::vector>& face_analytic_literals, + const std::vector& face_analytic_clocks) { + std::string payload; + append_boundary_request_bytes(payload, "pops.prepared-boundary-plan.request.v1"); + append_boundary_request_bytes(payload, name); + append_boundary_request_bytes(payload, identity); + append_boundary_request_int(payload, required_depth); + append_boundary_request_strings(payload, face_types); + append_boundary_request_reals(payload, face_values); + append_boundary_request_strings(payload, face_identities); + append_boundary_request_strings(payload, component_roles); + append_boundary_request_size(payload, omitted_interface_faces.size()); + for (int face : omitted_interface_faces) + append_boundary_request_int(payload, face); + append_boundary_request_bytes(payload, state_identity); + append_boundary_request_strings(payload, read_dependencies.states); + append_boundary_request_strings(payload, read_dependencies.fields); + append_boundary_request_size(payload, periodic_identifications.size()); + for (const auto& periodic : periodic_identifications) { + append_boundary_request_int(payload, periodic.source_face); + append_boundary_request_int(payload, periodic.target_face); + for (int axis : periodic.permutation) + append_boundary_request_int(payload, axis); + for (int sign : periodic.signs) + append_boundary_request_int(payload, sign); + } + append_boundary_request_strings(payload, face_representations); + append_boundary_request_strings(payload, face_converter_identities); + append_boundary_request_size(payload, face_analytic_opcodes.size()); + for (const auto& row : face_analytic_opcodes) + append_boundary_request_strings(payload, row); + append_boundary_request_size(payload, face_analytic_literals.size()); + for (const auto& row : face_analytic_literals) + append_boundary_request_reals(payload, row); + append_boundary_request_strings(payload, face_analytic_clocks); + return payload; +} + +} // namespace detail + +/// Native boundary plan captured by every block closure. The built-in physical-face authority is +/// one model-aware hyperbolic plan; field/elliptic BCRec data is not a transport semantic here. class PreparedBoundaryPlan { public: /// Move-only, lane-bound executable state for this immutable plan. @@ -103,9 +204,11 @@ class PreparedBoundaryPlan { lane_(std::exchange(other.lane_, nullptr)), component_revision_(std::exchange(other.component_revision_, 0)), ghost_components_(std::move(other.ghost_components_)), + flux_components_(std::move(other.flux_components_)), residual_components_(std::move(other.residual_components_)), jvp_components_(std::move(other.jvp_components_)), ghost_workspaces_(std::move(other.ghost_workspaces_)), + flux_workspaces_(std::move(other.flux_workspaces_)), residual_workspaces_(std::move(other.residual_workspaces_)), jvp_workspaces_(std::move(other.jvp_workspaces_)) {} Session& operator=(Session&& other) noexcept { @@ -114,9 +217,11 @@ class PreparedBoundaryPlan { lane_ = std::exchange(other.lane_, nullptr); component_revision_ = std::exchange(other.component_revision_, 0); ghost_components_ = std::move(other.ghost_components_); + flux_components_ = std::move(other.flux_components_); residual_components_ = std::move(other.residual_components_); jvp_components_ = std::move(other.jvp_components_); ghost_workspaces_ = std::move(other.ghost_workspaces_); + flux_workspaces_ = std::move(other.flux_workspaces_); residual_workspaces_ = std::move(other.residual_workspaces_); jvp_workspaces_ = std::move(other.jvp_workspaces_); } @@ -129,9 +234,15 @@ class PreparedBoundaryPlan { void fill_same_level_and_physical(MultiFab& state, const Box2D& domain) const; void fill_same_level_and_physical(MultiFab& state, const Geometry& geometry) const; + void fill_same_level_and_physical( + MultiFab& state, const Geometry& geometry, + const runtime::multiblock::BoundaryEvaluationPoint& point) const; void fill_same_level_and_physical( MultiFab& state, const detail::BoundaryFieldRegistry& fields, const Geometry& geometry, const runtime::multiblock::BoundaryEvaluationPoint& point) const; + void transform_fluxes(const runtime::multiblock::BoundaryEvaluationPoint& point, + const MultiFab& state, const detail::BoundaryFieldRegistry& fields, + const Geometry& geometry, MultiFab& fx, MultiFab& fy) const; void add_residual(const runtime::multiblock::BoundaryEvaluationPoint& point, const detail::BoundaryFieldRegistry& fields, const Geometry& geometry) const; void apply_jvp(const runtime::multiblock::BoundaryEvaluationPoint& point, @@ -143,6 +254,9 @@ class PreparedBoundaryPlan { void prepare_ghost_executor(const MultiFab& prototype, const detail::BoundaryFieldRegistry& fields, const Geometry& geometry); + void prepare_flux_executor(const MultiFab& prototype, + const detail::BoundaryFieldRegistry& fields, + const Geometry& geometry); void prepare_residual_executor(const detail::BoundaryFieldRegistry& fields, const Geometry& geometry); void prepare_jvp_executor(const detail::BoundaryFieldRegistry& fields, @@ -158,6 +272,9 @@ class PreparedBoundaryPlan { void fill_same_level_and_physical_control( MultiFab& state, const MultiFab* auxiliary, const Geometry& geometry, const runtime::multiblock::BoundaryEvaluationPoint& point) const; + void transform_fluxes_control(const runtime::multiblock::BoundaryEvaluationPoint& point, + const MultiFab& state, const MultiFab* auxiliary, + const Geometry& geometry, MultiFab& fx, MultiFab& fy) const; void add_residual_control(const runtime::multiblock::BoundaryEvaluationPoint& point, const MultiFab& state, const MultiFab* auxiliary, const Geometry& geometry, MultiFab& residual) const; @@ -170,22 +287,25 @@ class PreparedBoundaryPlan { const ExecutionLane* lane_ = nullptr; std::size_t component_revision_ = 0; std::vector ghost_components_; + std::vector flux_components_; std::vector residual_components_; std::vector jvp_components_; mutable std::vector ghost_workspaces_; + mutable std::vector flux_workspaces_; mutable std::vector residual_workspaces_; mutable std::vector jvp_workspaces_; }; PreparedBoundaryPlan() = default; - PreparedBoundaryPlan(std::string identity, int required_depth, std::vector component_bc, + PreparedBoundaryPlan(std::string identity, int required_depth, + PreparedHyperbolicBoundary<2> hyperbolic_boundary, std::vector omitted_face_ordinals = {}, std::string state_identity = {}, PreparedBoundaryReadDependencies read_dependencies = {}, std::vector periodic_identifications = {}) : identity_(std::move(identity)), required_depth_(required_depth), - component_bc_(std::move(component_bc)), + hyperbolic_boundary_(std::move(hyperbolic_boundary)), state_identity_(std::move(state_identity)), read_dependencies_(std::move(read_dependencies)), periodic_identifications_(std::move(periodic_identifications)) { @@ -201,10 +321,36 @@ class PreparedBoundaryPlan { const std::string& identity() const { return identity_; } const std::string& state_identity() const { return state_identity_; } int required_depth() const { return required_depth_; } - int ncomp() const { return static_cast(component_bc_.size()); } + int ncomp() const { return hyperbolic_boundary_.ncomp(); } + const PreparedHyperbolicBoundary<2>& hyperbolic_boundary() const { return hyperbolic_boundary_; } + bool requires_fixed_state_conversion() const { + return hyperbolic_boundary_.requires_fixed_state_conversion(); + } + /// Complete one model-dependent preparation step before any execution session is retained. + /// + /// The revision increment invalidates any session or dependency token created too early instead + /// of allowing it to observe a changed numerical table. + void prepare_fixed_state_conversion( + const std::function& primitive_to_conservative) { + if (!requires_fixed_state_conversion()) + return; + hyperbolic_boundary_ = + hyperbolic_boundary_.with_converted_fixed_states(primitive_to_conservative); + ++component_revision_; + } const std::vector& periodic_identifications() const noexcept { return periodic_identifications_; } + bool has_mapped_periodicity() const noexcept { return has_mapped_periodicity_(); } + std::optional axis_aligned_periodicity() const noexcept { + const bool xlo = hyperbolic_boundary_.face(0, -1).law == HyperbolicBoundaryLaw::Periodic; + const bool xhi = hyperbolic_boundary_.face(0, 1).law == HyperbolicBoundaryLaw::Periodic; + const bool ylo = hyperbolic_boundary_.face(1, -1).law == HyperbolicBoundaryLaw::Periodic; + const bool yhi = hyperbolic_boundary_.face(1, 1).law == HyperbolicBoundaryLaw::Periodic; + if (xlo != xhi || ylo != yhi) + return std::nullopt; + return Periodicity{xlo, ylo}; + } /// Validate that an already allocated state can execute this prepared plan. Installation-time /// consumers use the same invariant as the fill path, without performing or probing a fill. void validate_state_layout(const MultiFab& state) const { validate_for(state); } @@ -218,27 +364,16 @@ class PreparedBoundaryPlan { return omitted_faces_[static_cast(2 * axis + (side > 0 ? 1 : 0))]; } - const BCRec& component_bc(int comp) const { - if (comp < 0 || comp >= ncomp()) - throw std::runtime_error("PreparedBoundaryPlan component index out of range"); - return component_bc_[static_cast(comp)]; - } - - /// Materialize the immutable face law on one exact grid metric. BCRec stores spacing because - /// Robin extensions need the cell-to-face distance; that spacing is execution geometry, not part - /// of a reusable boundary plan's identity. - BCRec component_bc(int comp, const Geometry& geometry) const { - BCRec result = component_bc(comp); - result.dx = geometry.dx(); - result.dy = geometry.dy(); - return result; - } - void install_ghost_component(PreparedBoundaryComponentSpec spec, std::shared_ptr component) { install_typed_(ghost_components_, std::move(spec), std::move(component)); ++component_revision_; } + void install_flux_component(PreparedBoundaryComponentSpec spec, + std::shared_ptr component) { + install_typed_(flux_components_, std::move(spec), std::move(component)); + ++component_revision_; + } void install_residual_component(PreparedBoundaryComponentSpec spec, std::shared_ptr component) { install_typed_(residual_components_, std::move(spec), std::move(component)); @@ -256,12 +391,14 @@ class PreparedBoundaryPlan { } bool has_component_boundaries() const { - return !ghost_components_.empty() || !residual_components_.empty() || !jvp_components_.empty(); + return !ghost_components_.empty() || !flux_components_.empty() || + !residual_components_.empty() || !jvp_components_.empty(); } + bool has_flux_transformations() const noexcept { return !flux_components_.empty(); } - /// The built-in BCRec laws fill every ghost layer allocated by the state. A dynamically loaded - /// ghost component is prepared only for this plan's authenticated required_depth(), so it keeps - /// the bounded-depth contract even though residual/JVP-only components do not affect ghost fill. + /// The built-in hyperbolic laws fill every ghost layer allocated by the state. A dynamically + /// loaded ghost component is prepared only for this plan's authenticated required_depth(), so it + /// keeps the bounded-depth contract even though residual/JVP-only components do not affect fill. bool fills_all_allocated_physical_ghosts() const noexcept { return ghost_components_.empty(); } /// Whether this plan owns an executable residual/JVP pair for an implicit operator. A partial @@ -321,6 +458,8 @@ class PreparedBoundaryPlan { std::vector result = read_dependencies_.fields; for (const auto& component : ghost_components_) append_unique_(result, component->spec().fields); + for (const auto& component : flux_components_) + append_unique_(result, component->spec().fields); for (const auto& component : residual_components_) append_unique_(result, component->spec().fields); for (const auto& component : jvp_components_) @@ -332,6 +471,8 @@ class PreparedBoundaryPlan { std::vector result = read_dependencies_.states; for (const auto& component : ghost_components_) append_unique_(result, component->spec().states); + for (const auto& component : flux_components_) + append_unique_(result, component->spec().states); for (const auto& component : residual_components_) append_unique_(result, component->spec().states); for (const auto& component : jvp_components_) @@ -394,22 +535,11 @@ class PreparedBoundaryPlan { Periodicity periodicity() const { validate_topology(); - const BCRec& bc = component_bc_.front(); - const bool xlo = bc.xlo == BCType::Periodic; - const bool xhi = bc.xhi == BCType::Periodic; - const bool ylo = bc.ylo == BCType::Periodic; - const bool yhi = bc.yhi == BCType::Periodic; - if (xlo != xhi || ylo != yhi) + const auto result = axis_aligned_periodicity(); + if (!result) throw std::logic_error( "axis-permuted periodic topology has no per-axis runtime Periodicity projection"); - return Periodicity{xlo, ylo}; - } - - bool requires_grid_metric() const { - return std::any_of(component_bc_.begin(), component_bc_.end(), [](const BCRec& bc) { - return bc.xlo == BCType::Robin || bc.xhi == BCType::Robin || bc.ylo == BCType::Robin || - bc.yhi == BCType::Robin; - }); + return *result; } /// Same-level/MPI and prepared periodic production are performed by the memoized native halo @@ -419,13 +549,10 @@ class PreparedBoundaryPlan { if (has_component_boundaries()) throw std::runtime_error( "PreparedBoundaryPlan native components require an exact BoundaryEvaluationPoint"); - if (requires_grid_metric()) - throw std::invalid_argument( - "PreparedBoundaryPlan Robin boundaries require an exact Geometry metric"); validate_for(state); + auto physical_preflight = hyperbolic_boundary_.preflight_physical(state, domain); fill_native_halos_(state, domain); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, domain, component_bc(comp), comp); + hyperbolic_boundary_.fill_physical_preflighted(state, std::move(physical_preflight)); } void fill_same_level_and_physical(MultiFab& state, const Box2D& domain, @@ -433,13 +560,10 @@ class PreparedBoundaryPlan { if (has_component_boundaries()) throw std::runtime_error( "PreparedBoundaryPlan native components require an exact BoundaryEvaluationPoint"); - if (requires_grid_metric()) - throw std::invalid_argument( - "PreparedBoundaryPlan Robin boundaries require an exact Geometry metric"); validate_for(state); + auto physical_preflight = hyperbolic_boundary_.preflight_physical(state, domain); fill_native_halos_(state, domain, lane); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, domain, component_bc(comp), comp); + hyperbolic_boundary_.fill_physical_preflighted(state, std::move(physical_preflight)); } void fill_same_level_and_physical(MultiFab& state, const Geometry& geometry) const { @@ -447,9 +571,9 @@ class PreparedBoundaryPlan { throw std::runtime_error( "PreparedBoundaryPlan native components require an exact BoundaryEvaluationPoint"); validate_for(state); + auto physical_preflight = hyperbolic_boundary_.preflight_physical(state, geometry); fill_native_halos_(state, geometry.domain); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, geometry.domain, component_bc(comp, geometry), comp); + hyperbolic_boundary_.fill_physical_preflighted(state, std::move(physical_preflight)); } void fill_same_level_and_physical(MultiFab& state, const Geometry& geometry, @@ -458,9 +582,10 @@ class PreparedBoundaryPlan { throw std::runtime_error( "PreparedBoundaryPlan native components require an exact BoundaryEvaluationPoint"); validate_for(state); + auto physical_preflight = + hyperbolic_boundary_.preflight_physical(state, geometry, lane.communicator()); fill_native_halos_(state, geometry.domain, lane); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, geometry.domain, component_bc(comp, geometry), comp); + hyperbolic_boundary_.fill_physical_preflighted(state, std::move(physical_preflight)); } /// One-shot control/diagnostic adapter. It materializes a fresh component session and workspace; @@ -562,6 +687,16 @@ class PreparedBoundaryPlan { session.apply_jvp(point, fields, geometry); } + void transform_fluxes_control(const runtime::multiblock::BoundaryEvaluationPoint& point, + const MultiFab& state, const MultiFab* auxiliary, + const Geometry& geometry, MultiFab& fx, MultiFab& fy, + const ExecutionLane& lane = ExecutionLane::world()) const { + if (!has_flux_transformations()) + return; + auto session = make_session(lane); + session.transform_fluxes_control(point, state, auxiliary, geometry, fx, fy); + } + void apply_jvp_control(const runtime::multiblock::BoundaryEvaluationPoint& point, const detail::BoundaryFieldRegistry& fields, const Geometry& geometry, const ExecutionLane& lane) const { @@ -575,12 +710,13 @@ class PreparedBoundaryPlan { std::string identity_; int required_depth_ = 0; - std::vector component_bc_; + PreparedHyperbolicBoundary<2> hyperbolic_boundary_; std::array omitted_faces_{{false, false, false, false}}; std::string state_identity_; PreparedBoundaryReadDependencies read_dependencies_; std::vector periodic_identifications_; std::vector> ghost_components_; + std::vector> flux_components_; std::vector> residual_components_; std::vector> jvp_components_; std::size_t component_revision_ = 0; @@ -667,10 +803,6 @@ class PreparedBoundaryPlan { "PreparedBoundaryPlan residual/JVP direction identities are not executable"); } - static std::array face_types(const BCRec& bc) { - return {bc.xlo, bc.xhi, bc.ylo, bc.yhi}; - } - bool has_mapped_periodicity_() const noexcept { return std::any_of(periodic_identifications_.begin(), periodic_identifications_.end(), [](const PeriodicIdentification2D& identification) { @@ -691,14 +823,14 @@ class PreparedBoundaryPlan { else fill_boundary(state, domain, lane, periodicity()); } - void validate_base() const { if (identity_.empty()) throw std::runtime_error("PreparedBoundaryPlan requires a canonical identity"); if (required_depth_ < 1) throw std::runtime_error("PreparedBoundaryPlan required depth must be >= 1"); - if (component_bc_.empty()) - throw std::runtime_error("PreparedBoundaryPlan requires one BC record per component"); + if (hyperbolic_boundary_.ncomp() < 1) + throw std::runtime_error( + "PreparedBoundaryPlan requires one model-aware component transform per state component"); validate_read_dependencies_(read_dependencies_.states, "state"); validate_read_dependencies_(read_dependencies_.fields, "field"); validate_topology(); @@ -720,22 +852,13 @@ class PreparedBoundaryPlan { } void validate_topology() const { - if (component_bc_.empty()) - throw std::runtime_error("PreparedBoundaryPlan has no component BCs"); - const auto expected = face_types(component_bc_.front()); - for (std::size_t comp = 1; comp < component_bc_.size(); ++comp) { - const auto actual = face_types(component_bc_[comp]); - for (std::size_t face = 0; face < actual.size(); ++face) { - const bool expected_periodic = expected[face] == BCType::Periodic; - const bool actual_periodic = actual[face] == BCType::Periodic; - if (expected_periodic != actual_periodic) - throw std::runtime_error( - "PreparedBoundaryPlan periodic/physical topology differs between components"); - } - } + std::array periodic{}; + for (int face = 0; face < 4; ++face) + periodic[static_cast(face)] = + hyperbolic_boundary_.face(face / 2, face % 2 == 0 ? -1 : 1).law == + HyperbolicBoundaryLaw::Periodic; if (periodic_identifications_.empty()) { - if ((expected[0] == BCType::Periodic) != (expected[1] == BCType::Periodic) || - (expected[2] == BCType::Periodic) != (expected[3] == BCType::Periodic)) + if (periodic[0] != periodic[1] || periodic[2] != periodic[3]) throw std::runtime_error( "axis-aligned PreparedBoundaryPlan requires periodic faces in complete axis pairs"); return; @@ -749,21 +872,31 @@ class PreparedBoundaryPlan { throw std::runtime_error( "PreparedBoundaryPlan assigns one face to multiple periodic identifications"); claimed[static_cast(face)] = true; - if (expected[static_cast(face)] != BCType::Periodic) + if (!periodic[static_cast(face)]) throw std::runtime_error( "PreparedBoundaryPlan periodic identification endpoint is not a periodic face"); } } - for (std::size_t face = 0; face < expected.size(); ++face) - if ((expected[face] == BCType::Periodic) != claimed[face]) + for (std::size_t face = 0; face < periodic.size(); ++face) + if (periodic[face] != claimed[face]) throw std::runtime_error( "PreparedBoundaryPlan periodic face table differs from explicit identifications"); if (has_mapped_periodicity_() && periodic_identifications_.size() != 1) throw std::runtime_error( "PreparedBoundaryPlan mapped periodic topology currently requires one identification; " "mixed periodic corners need a composed scheduler"); + if (has_mapped_periodicity_() && hyperbolic_boundary_.has_analytic_state()) + throw std::runtime_error( + "PreparedBoundaryPlan analytic faces do not yet support mapped periodic coordinates; " + "install an axis-aligned periodic identification or a prepared coordinate map"); + if (has_mapped_periodicity_()) + for (int component = 0; component < hyperbolic_boundary_.ncomp(); ++component) + if (hyperbolic_boundary_.component_transform(component).parity != + HyperbolicComponentParity::Scalar) + throw std::runtime_error( + "mapped periodic topology currently supports scalar component transforms only; " + "vector and axial states require a model-aware component map"); } - void validate_for(const MultiFab& state) const { if (state.ncomp() != ncomp()) throw std::runtime_error("PreparedBoundaryPlan component count does not match block state"); @@ -777,10 +910,13 @@ inline PreparedBoundaryPlan::Session::Session(const PreparedBoundaryPlan& plan, : plan_(&plan), lane_(&lane), component_revision_(plan.component_revision_) { plan.validate_base(); ghost_components_.reserve(plan.ghost_components_.size()); + flux_components_.reserve(plan.flux_components_.size()); residual_components_.reserve(plan.residual_components_.size()); jvp_components_.reserve(plan.jvp_components_.size()); for (const auto& component : plan.ghost_components_) ghost_components_.push_back(component->make_session(lane)); + for (const auto& component : plan.flux_components_) + flux_components_.push_back(component->make_session(lane)); for (const auto& component : plan.residual_components_) residual_components_.push_back(component->make_session(lane)); for (const auto& component : plan.jvp_components_) @@ -801,13 +937,10 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical(MultiFab if (!ghost_components_.empty()) throw std::invalid_argument( "PreparedBoundaryPlan component session requires an exact BoundaryEvaluationPoint"); - if (plan_->requires_grid_metric()) - throw std::invalid_argument( - "PreparedBoundaryPlan Robin boundaries require an exact Geometry metric"); plan_->validate_for(state); + auto physical_preflight = plan_->hyperbolic_boundary_.preflight_physical(state, domain); plan_->fill_native_halos_(state, domain, *lane_); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, domain, plan_->component_bc(comp), comp); + plan_->hyperbolic_boundary_.fill_physical_preflighted(state, std::move(physical_preflight)); } inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( @@ -817,9 +950,24 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( throw std::invalid_argument( "PreparedBoundaryPlan component session requires an exact BoundaryEvaluationPoint"); plan_->validate_for(state); + auto physical_preflight = + plan_->hyperbolic_boundary_.preflight_physical(state, geometry, lane_->communicator()); + plan_->fill_native_halos_(state, geometry.domain, *lane_); + plan_->hyperbolic_boundary_.fill_physical_preflighted(state, std::move(physical_preflight)); +} + +inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( + MultiFab& state, const Geometry& geometry, + const runtime::multiblock::BoundaryEvaluationPoint& point) const { + validate_current_(); + if (!ghost_components_.empty()) + throw std::invalid_argument( + "PreparedBoundaryPlan component session requires its prepared field registry"); + plan_->validate_for(state); + auto physical_preflight = plan_->hyperbolic_boundary_.preflight_physical( + state, geometry, static_cast(point.physical_time), point.clock, lane_->communicator()); plan_->fill_native_halos_(state, geometry.domain, *lane_); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, geometry.domain, plan_->component_bc(comp, geometry), comp); + plan_->hyperbolic_boundary_.fill_physical_preflighted(state, std::move(physical_preflight)); } inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical_control( @@ -827,9 +975,10 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical_control( const runtime::multiblock::BoundaryEvaluationPoint& point) const { validate_current_(); plan_->validate_for(state); + auto physical_preflight = plan_->hyperbolic_boundary_.preflight_physical( + state, geometry, static_cast(point.physical_time), point.clock, lane_->communicator()); plan_->fill_native_halos_(state, geometry.domain, *lane_); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, geometry.domain, plan_->component_bc(comp, geometry), comp); + plan_->hyperbolic_boundary_.fill_physical_preflighted(state, std::move(physical_preflight)); detail::BoundaryFieldRegistry fields; fields.configure_states(plan_->required_state_identities()); fields.configure_fields(plan_->required_field_identities()); @@ -859,9 +1008,10 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( const runtime::multiblock::BoundaryEvaluationPoint& point) const { validate_current_(); plan_->validate_for(state); + auto physical_preflight = plan_->hyperbolic_boundary_.preflight_physical( + state, geometry, static_cast(point.physical_time), point.clock, lane_->communicator()); plan_->fill_native_halos_(state, geometry.domain, *lane_); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, geometry.domain, plan_->component_bc(comp, geometry), comp); + plan_->hyperbolic_boundary_.fill_physical_preflighted(state, std::move(physical_preflight)); if (ghost_workspaces_.size() != ghost_components_.size()) throw std::logic_error( "PreparedBoundaryPlan ghost executor was not materialized before numerical execution"); @@ -870,6 +1020,51 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( geometry, point); } +inline void PreparedBoundaryPlan::Session::transform_fluxes_control( + const runtime::multiblock::BoundaryEvaluationPoint& point, const MultiFab& state, + const MultiFab* auxiliary, const Geometry& geometry, MultiFab& fx, MultiFab& fy) const { + validate_current_(); + if (flux_components_.empty()) + return; + detail::BoundaryFieldRegistry fields; + fields.configure_states(plan_->required_state_identities()); + fields.configure_fields(plan_->required_field_identities()); + fields.begin_binding(); + const auto states = plan_->required_state_identities(); + if (!states.empty()) { + if (states.size() != 1 || states.front() != plan_->state_identity()) + throw std::runtime_error( + "post-Riemann boundary flux with multiple states requires the N-ary prepared registry " + "seam"); + fields.bind_state(states.front(), state); + } + const auto dependencies = plan_->required_field_identities(); + if (!dependencies.empty()) { + if (dependencies.size() != 1 || auxiliary == nullptr) + throw std::runtime_error( + "post-Riemann boundary flux fields require the N-ary prepared registry seam"); + fields.bind_field(dependencies.front(), *auxiliary); + } + for (const auto& component : flux_components_) { + auto workspace = detail::prepare_boundary_flux_workspace(component, state, fields, geometry); + detail::apply_boundary_flux_component(component, workspace, state, fields, geometry, fx, fy, + point); + } +} + +inline void PreparedBoundaryPlan::Session::transform_fluxes( + const runtime::multiblock::BoundaryEvaluationPoint& point, const MultiFab& state, + const detail::BoundaryFieldRegistry& fields, const Geometry& geometry, MultiFab& fx, + MultiFab& fy) const { + validate_current_(); + if (flux_workspaces_.size() != flux_components_.size()) + throw std::logic_error( + "PreparedBoundaryPlan flux executor was not materialized before numerical execution"); + for (std::size_t index = 0; index < flux_components_.size(); ++index) + detail::apply_boundary_flux_component(flux_components_[index], flux_workspaces_[index], state, + fields, geometry, fx, fy, point); +} + inline void PreparedBoundaryPlan::Session::add_residual_control( const runtime::multiblock::BoundaryEvaluationPoint& point, const MultiFab& state, const MultiFab* auxiliary, const Geometry& geometry, MultiFab& residual) const { @@ -979,6 +1174,18 @@ inline void PreparedBoundaryPlan::Session::prepare_ghost_executor( geometry, plan_->required_depth_)); } +inline void PreparedBoundaryPlan::Session::prepare_flux_executor( + const MultiFab& prototype, const detail::BoundaryFieldRegistry& fields, + const Geometry& geometry) { + validate_current_(); + plan_->validate_for(prototype); + flux_workspaces_.clear(); + flux_workspaces_.reserve(flux_components_.size()); + for (const auto& component : flux_components_) + flux_workspaces_.push_back( + detail::prepare_boundary_flux_workspace(component, prototype, fields, geometry)); +} + inline void PreparedBoundaryPlan::Session::prepare_residual_executor( const detail::BoundaryFieldRegistry& fields, const Geometry& geometry) { validate_current_(); diff --git a/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp new file mode 100644 index 000000000..ce71356d2 --- /dev/null +++ b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp @@ -0,0 +1,1095 @@ +/// @file +/// @brief One prepared, model-aware physical-boundary authority for hyperbolic state transport. +/// +/// Boundary topology (periodic/external) and physical law (extrapolation, fixed state, reflective +/// slip wall) are represented independently. Component transforms are resolved from model roles +/// before a numerical loop; face kernels therefore execute one immutable table without model +/// switches, component-index inference, Python callbacks, or per-cell allocation. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(POPS_HAS_KOKKOS) +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pops { + +enum class HyperbolicBoundaryLaw { Periodic, Extrapolate, FixedState, ReflectiveSlip, External }; + +enum class HyperbolicComponentParity { Scalar, PolarVector, AxialVector }; + +/// Representation in which a fixed-state face was authored. +/// +/// Native face kernels consume conservative values. Primitive data therefore remains explicitly +/// pending until the owning compiled block supplies its exact pointwise model conversion. +enum class HyperbolicStateRepresentation { Conservative, Primitive }; + +/// Reflection behavior of one model-qualified state component. +/// +/// A polar vector reverses its normal component at a reflective plane. An axial vector applies +/// det(R)R, so its normal component is preserved and every tangential component reverses. Scalars +/// are even. The axis is a three-dimensional physical-component axis, never inferred from the +/// component index. It is intentionally independent of @p Dim: a 1D/2D mesh may evolve transverse +/// polar components or an out-of-plane axial component (the usual 2.5D case). +template +struct HyperbolicComponentTransform { + static_assert(Dim >= 1 && Dim <= 3); + + HyperbolicComponentParity parity = HyperbolicComponentParity::Scalar; + int axis = -1; + + static HyperbolicComponentTransform scalar() { return {}; } + static HyperbolicComponentTransform polar_vector(int component_axis) { + if (component_axis < 0 || component_axis >= 3) + throw std::invalid_argument( + "polar boundary component axis is outside the physical x/y/z embedding"); + return {HyperbolicComponentParity::PolarVector, component_axis}; + } + static HyperbolicComponentTransform axial_vector(int component_axis) { + if (component_axis < 0 || component_axis >= 3) + throw std::invalid_argument( + "axial boundary component axis is outside the physical x/y/z embedding"); + return {HyperbolicComponentParity::AxialVector, component_axis}; + } + + POPS_HD Real reflection_sign(int normal_axis) const { + if (parity == HyperbolicComponentParity::Scalar) + return Real(1); + const bool normal_component = axis == normal_axis; + if (parity == HyperbolicComponentParity::PolarVector) + return normal_component ? Real(-1) : Real(1); + return normal_component ? Real(1) : Real(-1); + } +}; + +/// Exact axis-aligned face context supplied to a prepared physical law. +/// +/// The pointer fields are optional device-accessible packs. Built-in constant/extrapolation/wall +/// laws do not read them; compiled analytic providers may consume them without a Python callback. +template +struct HyperbolicFaceContext { + static_assert(Dim >= 1 && Dim <= 3); + + int axis = 0; + int side = -1; + std::array coordinate{}; + std::array normal{}; + std::array, (Dim > 1 ? Dim - 1 : 0)> tangents{}; + Real metric = Real(1); + Real area = Real(1); + Real time = Real(0); + const Real* runtime_parameters = nullptr; + int runtime_parameter_count = 0; + const Real* auxiliary_values = nullptr; + int auxiliary_value_count = 0; + std::uint64_t boundary_identity = 0; +}; + +struct PreparedHyperbolicFace { + HyperbolicBoundaryLaw law = HyperbolicBoundaryLaw::Periodic; + std::string identity; + std::uint64_t identity_token = 0; + std::vector fixed_state; + HyperbolicStateRepresentation authored_representation = + HyperbolicStateRepresentation::Conservative; + std::string converter_identity; + bool fixed_state_converted = true; + std::vector analytic_state; + std::string analytic_clock; +}; + +/// Dimension-split FV stencils do not read double-physical corners. Such corners are therefore +/// explicitly excluded rather than being assigned an implicit X-then-Y precedence. +enum class HyperbolicCornerPolicy { NotRequired }; + +namespace detail { + +inline std::uint64_t stable_boundary_identity(std::string_view identity) { + if (identity.empty()) + throw std::invalid_argument("hyperbolic boundary identity must be non-empty"); + std::uint64_t value = UINT64_C(1469598103934665603); + for (const unsigned char byte : identity) { + value ^= static_cast(byte); + value *= UINT64_C(1099511628211); + } + return value; +} + +template +struct HyperbolicBoundaryTableView { + const HyperbolicComponentTransform* transforms = nullptr; + const Real* fixed_values = nullptr; + int ncomp = 0; + + POPS_HD const HyperbolicComponentTransform& transform(int component) const { + return transforms[component]; + } + POPS_HD Real fixed_value(int face, int component) const { + return fixed_values[face * ncomp + component]; + } +}; + +struct HyperbolicBoundarySample { + int source; + Real scale; + Real offset; +}; + +template +POPS_HD inline HyperbolicBoundarySample hyperbolic_boundary_sample_1d( + int index, int lo, int hi, int axis, HyperbolicBoundaryLaw low, HyperbolicBoundaryLaw high, + const HyperbolicBoundaryTableView& table, int component) { + std::int64_t current = index; + Real scale = Real(1); + Real offset = Real(0); + while (current < lo || current > hi) { + const bool below = current < lo; + const HyperbolicBoundaryLaw law = below ? low : high; + const std::int64_t boundary = below ? lo : hi; + if (law == HyperbolicBoundaryLaw::Extrapolate) { + current = boundary; + break; + } + + Real face_scale = Real(1); + Real face_offset = Real(0); + if (law == HyperbolicBoundaryLaw::FixedState) { + face_scale = Real(-1); + const int face = 2 * axis + (below ? 0 : 1); + face_offset = Real(2) * table.fixed_value(face, component); + } else if (law == HyperbolicBoundaryLaw::ReflectiveSlip) { + face_scale = table.transform(component).reflection_sign(axis); + } else { + // Installation preflight rejects an extension that can reach periodic/external ownership. + current = boundary; + break; + } + offset += scale * face_offset; + scale *= face_scale; + current = below ? 2 * boundary - current - 1 : 2 * boundary - current + 1; + } + return {static_cast(current), scale, offset}; +} + +inline bool is_physical_hyperbolic_law(HyperbolicBoundaryLaw law) { + return law == HyperbolicBoundaryLaw::Extrapolate || law == HyperbolicBoundaryLaw::FixedState || + law == HyperbolicBoundaryLaw::ReflectiveSlip; +} + +inline const char* hyperbolic_law_name(HyperbolicBoundaryLaw law) { + switch (law) { + case HyperbolicBoundaryLaw::Periodic: + return "periodic"; + case HyperbolicBoundaryLaw::Extrapolate: + return "extrapolate"; + case HyperbolicBoundaryLaw::FixedState: + return "fixed_state"; + case HyperbolicBoundaryLaw::ReflectiveSlip: + return "reflective_slip"; + case HyperbolicBoundaryLaw::External: + return "external"; + } + return "unknown"; +} + +template +inline void validate_hyperbolic_extension(int index, int lo, int hi, int axis, + HyperbolicBoundaryLaw low, HyperbolicBoundaryLaw high, + const HyperbolicBoundaryTableView& table, + int component) { + std::int64_t current = index; + Real scale = Real(1); + Real offset = Real(0); + while (current < lo || current > hi) { + const bool below = current < lo; + const HyperbolicBoundaryLaw law = below ? low : high; + const std::int64_t boundary = below ? lo : hi; + if (!is_physical_hyperbolic_law(law)) + throw std::invalid_argument(std::string("prepared hyperbolic halo reaches a ") + + hyperbolic_law_name(law) + + " face whose values belong to another topology authority"); + if (law == HyperbolicBoundaryLaw::Extrapolate) + return; + + Real face_scale = Real(1); + Real face_offset = Real(0); + if (law == HyperbolicBoundaryLaw::FixedState) { + face_scale = Real(-1); + const int face = 2 * axis + (below ? 0 : 1); + face_offset = Real(2) * table.fixed_value(face, component); + } else { + face_scale = table.transform(component).reflection_sign(axis); + } + offset += scale * face_offset; + scale *= face_scale; + if (!std::isfinite(scale) || !std::isfinite(offset)) + throw std::overflow_error("prepared hyperbolic halo produced a non-finite affine extension"); + current = below ? 2 * boundary - current - 1 : 2 * boundary - current + 1; + } +} + +template +struct HyperbolicFaceXKernel { + Array4 state; + HyperbolicBoundaryTableView table; + int lo; + int hi; + HyperbolicBoundaryLaw low; + HyperbolicBoundaryLaw high; + + POPS_HD void operator()(int i, int j) const { + for (int component = 0; component < table.ncomp; ++component) { + const auto sample = hyperbolic_boundary_sample_1d(i, lo, hi, 0, low, high, table, component); + state(i, j, component) = sample.scale * state(sample.source, j, component) + sample.offset; + } + } +}; + +template +struct HyperbolicFaceYKernel { + Array4 state; + HyperbolicBoundaryTableView table; + int lo; + int hi; + HyperbolicBoundaryLaw low; + HyperbolicBoundaryLaw high; + + POPS_HD void operator()(int i, int j) const { + for (int component = 0; component < table.ncomp; ++component) { + const auto sample = hyperbolic_boundary_sample_1d(j, lo, hi, 1, low, high, table, component); + state(i, j, component) = sample.scale * state(i, sample.source, component) + sample.offset; + } + } +}; + +POPS_HD inline int periodic_index(int index, int lo, int hi) { + const int count = hi - lo + 1; + int offset = (index - lo) % count; + if (offset < 0) + offset += count; + return lo + offset; +} + +struct AnalyticFixedSource { + int i = 0; + int j = 0; +}; + +template +struct AnalyticFixedFaceEvaluator { + static_assert(Axis == 0 || Axis == 1); + + // The analytic value is the Dirichlet trace on the physical face, not a ghost-cell sample. + // AnalyticFixedFaceKernel applies the same affine mirror rule as a constant FixedState face. + analytic::AnalyticProgramView program; + Geometry geometry; + int side; + bool periodic_tangent; + Real time; + + POPS_HD analytic::AnalyticEvaluation evaluate(int i, int j) const { + int coordinate_i = i; + int coordinate_j = j; + if constexpr (Axis == 0) { + if (periodic_tangent) + coordinate_j = periodic_index(j, geometry.domain.lo[1], geometry.domain.hi[1]); + } else if (periodic_tangent) { + coordinate_i = periodic_index(i, geometry.domain.lo[0], geometry.domain.hi[0]); + } + const Real x = + Axis == 0 ? (side < 0 ? geometry.xlo : geometry.xhi) : geometry.x_cell(coordinate_i); + const Real y = + Axis == 1 ? (side < 0 ? geometry.ylo : geometry.yhi) : geometry.y_cell(coordinate_j); + return program.eval_checked(x, y, &time, std::uint8_t{1}); + } + + POPS_HD AnalyticFixedSource source(int i, int j) const { + if constexpr (Axis == 0) { + const int boundary = side < 0 ? geometry.domain.lo[0] : geometry.domain.hi[0]; + return {side < 0 ? 2 * boundary - i - 1 : 2 * boundary - i + 1, j}; + } else { + const int boundary = side < 0 ? geometry.domain.lo[1] : geometry.domain.hi[1]; + return {i, side < 0 ? 2 * boundary - j - 1 : 2 * boundary - j + 1}; + } + } +}; + +template +struct AnalyticFixedFaceFiniteKernel { + AnalyticFixedFaceEvaluator evaluator; + + POPS_HD Real operator()(int i, int j) const { + return evaluator.evaluate(i, j).valid ? Real(0) : Real(1); + } +}; + +template +struct AnalyticFixedFaceKernel { + Array4 state; + int component; + AnalyticFixedFaceEvaluator evaluator; + + POPS_HD void operator()(int i, int j) const { + const auto value = evaluator.evaluate(i, j); + const auto source = evaluator.source(i, j); + state(i, j, component) = Real(2) * value.value - state(source.i, source.j, component); + } +}; + +template +inline HyperbolicComponentTransform transform_from_role(std::string_view role) { + if (role == "MomentumX" || role == "VelocityX") + return HyperbolicComponentTransform::polar_vector(0); + if (role == "MomentumY" || role == "VelocityY") + return HyperbolicComponentTransform::polar_vector(1); + if (role == "MomentumZ" || role == "VelocityZ") + return HyperbolicComponentTransform::polar_vector(2); + if (role == "AxialX") + return HyperbolicComponentTransform::axial_vector(0); + if (role == "AxialY") + return HyperbolicComponentTransform::axial_vector(1); + if (role == "AxialZ") + return HyperbolicComponentTransform::axial_vector(2); + if (role == "Density" || role == "Energy" || role == "Pressure" || role == "Temperature" || + role == "Scalar" || role == "Custom") + return HyperbolicComponentTransform::scalar(); + throw std::invalid_argument("unsupported hyperbolic boundary component role '" + + std::string(role) + "'"); +} + +inline HyperbolicBoundaryLaw hyperbolic_law_from_token(std::string_view token) { + if (token == "periodic") + return HyperbolicBoundaryLaw::Periodic; + if (token == "foextrap") + return HyperbolicBoundaryLaw::Extrapolate; + if (token == "dirichlet") + return HyperbolicBoundaryLaw::FixedState; + if (token == "slip_wall") + return HyperbolicBoundaryLaw::ReflectiveSlip; + if (token == "external") + return HyperbolicBoundaryLaw::External; + throw std::invalid_argument("unsupported prepared hyperbolic face law '" + std::string(token) + + "'"); +} + +inline HyperbolicStateRepresentation hyperbolic_representation_from_token(std::string_view token) { + if (token == "conservative") + return HyperbolicStateRepresentation::Conservative; + if (token == "primitive") + return HyperbolicStateRepresentation::Primitive; + throw std::invalid_argument("unsupported prepared hyperbolic representation '" + + std::string(token) + "'"); +} + +} // namespace detail + +template +class PreparedHyperbolicBoundary { + public: + static_assert(Dim >= 1 && Dim <= 3); + using Transform = HyperbolicComponentTransform; + + /// Opaque proof that every fallible physical-face check, including the collective finite-value + /// scan of analytic programs, completed for one exact state/layout. PreparedBoundaryPlan obtains + /// this proof before mutating same-level, periodic or MPI halos, then consumes it immediately. + class PhysicalFillPreflight final { + public: + PhysicalFillPreflight(const PhysicalFillPreflight&) = delete; + PhysicalFillPreflight& operator=(const PhysicalFillPreflight&) = delete; + PhysicalFillPreflight(PhysicalFillPreflight&& other) noexcept + : owner_(std::exchange(other.owner_, nullptr)), + state_(std::exchange(other.state_, nullptr)), + domain_(other.domain_), + geometry_(other.geometry_), + has_geometry_(other.has_geometry_), + physical_time_(other.physical_time_), + ncomp_(other.ncomp_), + depth_(other.depth_) {} + PhysicalFillPreflight& operator=(PhysicalFillPreflight&& other) noexcept { + if (this == &other) + return *this; + owner_ = std::exchange(other.owner_, nullptr); + state_ = std::exchange(other.state_, nullptr); + domain_ = other.domain_; + geometry_ = other.geometry_; + has_geometry_ = other.has_geometry_; + physical_time_ = other.physical_time_; + ncomp_ = other.ncomp_; + depth_ = other.depth_; + return *this; + } + + private: + friend class PreparedHyperbolicBoundary; + + PhysicalFillPreflight(const PreparedHyperbolicBoundary* owner, const MultiFab* state, + Box2D domain, const Geometry* geometry, Real physical_time) + : owner_(owner), + state_(state), + domain_(domain), + geometry_(geometry == nullptr ? Geometry{} : *geometry), + has_geometry_(geometry != nullptr), + physical_time_(physical_time), + ncomp_(state->ncomp()), + depth_(state->n_grow()) {} + + const PreparedHyperbolicBoundary* owner_ = nullptr; + const MultiFab* state_ = nullptr; + Box2D domain_{}; + Geometry geometry_{}; + bool has_geometry_ = false; + Real physical_time_ = Real(0); + int ncomp_ = 0; + int depth_ = 0; + }; + + PreparedHyperbolicBoundary() = default; + + PreparedHyperbolicBoundary( + std::array faces, + std::vector component_transforms, + HyperbolicCornerPolicy corner_policy = HyperbolicCornerPolicy::NotRequired, + bool explicit_periodic_identifications = false) + : faces_(std::move(faces)), + component_transforms_(std::move(component_transforms)), + corner_policy_(corner_policy), + explicit_periodic_identifications_(explicit_periodic_identifications) { + validate(explicit_periodic_identifications); + prepare_device_tables(); + } + + int ncomp() const { return static_cast(component_transforms_.size()); } + const PreparedHyperbolicFace& face(int axis, int side) const { + if (axis < 0 || axis >= Dim || (side != -1 && side != 1)) + throw std::out_of_range("prepared hyperbolic face selector is outside the model dimension"); + return faces_[static_cast(2 * axis + (side > 0 ? 1 : 0))]; + } + const Transform& component_transform(int component) const { + if (component < 0 || component >= ncomp()) + throw std::out_of_range("prepared hyperbolic component is outside the state"); + return component_transforms_[static_cast(component)]; + } + HyperbolicCornerPolicy corner_policy() const { return corner_policy_; } + bool has_analytic_state() const { + return std::any_of(faces_.begin(), faces_.end(), [](const PreparedHyperbolicFace& face) { + return !face.analytic_state.empty(); + }); + } + + bool requires_fixed_state_conversion() const { + return std::any_of(faces_.begin(), faces_.end(), [](const PreparedHyperbolicFace& prepared) { + return prepared.law == HyperbolicBoundaryLaw::FixedState && + prepared.authored_representation == HyperbolicStateRepresentation::Primitive && + !prepared.fixed_state_converted; + }); + } + + /// Return a new executable table after converting every pending primitive fixed state. + /// + /// Conversion is transactional: this immutable table is untouched if the model conversion + /// throws or produces a non-finite component. + PreparedHyperbolicBoundary with_converted_fixed_states( + const std::function& primitive_to_conservative) const { + if (!requires_fixed_state_conversion()) + return *this; + if (!primitive_to_conservative) + throw std::invalid_argument( + "primitive fixed-state boundary requires the compiled block-model conversion"); + + auto converted_faces = faces_; + std::vector input(static_cast(ncomp())); + std::vector output(static_cast(ncomp())); + for (auto& prepared : converted_faces) { + if (prepared.law != HyperbolicBoundaryLaw::FixedState || + prepared.authored_representation != HyperbolicStateRepresentation::Primitive || + prepared.fixed_state_converted) + continue; + for (int component = 0; component < ncomp(); ++component) + input[static_cast(component)] = + static_cast(prepared.fixed_state[static_cast(component)]); + std::fill(output.begin(), output.end(), std::numeric_limits::quiet_NaN()); + primitive_to_conservative(input.data(), output.data()); + if (std::any_of(output.begin(), output.end(), + [](double value) { return !std::isfinite(value); })) + throw std::runtime_error( + "primitive fixed-state boundary conversion produced a non-finite component"); + for (int component = 0; component < ncomp(); ++component) { + const Real converted = static_cast(output[static_cast(component)]); + if (!std::isfinite(converted)) + throw std::runtime_error( + "primitive fixed-state boundary conversion exceeds the runtime precision"); + prepared.fixed_state[static_cast(component)] = converted; + } + prepared.fixed_state_converted = true; + } + return PreparedHyperbolicBoundary(std::move(converted_faces), component_transforms_, + corner_policy_, explicit_periodic_identifications_); + } + + Periodicity periodicity() const { + static_assert(Dim == 2, "the current MultiFab topology is two-dimensional"); + const bool xlo = faces_[0].law == HyperbolicBoundaryLaw::Periodic; + const bool xhi = faces_[1].law == HyperbolicBoundaryLaw::Periodic; + const bool ylo = faces_[2].law == HyperbolicBoundaryLaw::Periodic; + const bool yhi = faces_[3].law == HyperbolicBoundaryLaw::Periodic; + if (xlo != xhi || ylo != yhi) + throw std::logic_error( + "axis-permuted periodic topology has no per-axis runtime Periodicity projection"); + return Periodicity{ + xlo, + ylo, + }; + } + + /// Fill only physical faces. Same-level/MPI and periodic topology remain owned by fill_boundary. + /// + /// The explicit NotRequired corner policy excludes double-physical corners. Periodic tangential + /// ghosts are included because they were already produced by fill_boundary and are valid inputs. + void fill_physical(MultiFab& state, const Box2D& domain) const { + auto preflight = preflight_physical(state, domain); + fill_physical_preflighted(state, std::move(preflight)); + } + + void fill_physical(MultiFab& state, const Geometry& geometry) const { + fill_physical(state, geometry, world_communicator_view()); + } + + void fill_physical(MultiFab& state, const Geometry& geometry, + CommunicatorView communicator) const { + auto preflight = preflight_physical(state, geometry, communicator); + fill_physical_preflighted(state, std::move(preflight)); + } + + void fill_physical(MultiFab& state, const Geometry& geometry, Real physical_time, + std::string_view clock) const { + fill_physical(state, geometry, physical_time, clock, world_communicator_view()); + } + + void fill_physical(MultiFab& state, const Geometry& geometry, Real physical_time, + std::string_view clock, CommunicatorView communicator) const { + auto preflight = preflight_physical(state, geometry, physical_time, clock, communicator); + fill_physical_preflighted(state, std::move(preflight)); + } + + PhysicalFillPreflight preflight_physical(MultiFab& state, const Box2D& domain) const { + if (has_analytic_state()) + throw std::logic_error( + "analytic hyperbolic boundary requires physical Geometry at execution"); + return preflight_physical_impl_(state, domain, nullptr, Real(0), {}, false, + world_communicator_view()); + } + + PhysicalFillPreflight preflight_physical(MultiFab& state, const Geometry& geometry) const { + return preflight_physical(state, geometry, world_communicator_view()); + } + + PhysicalFillPreflight preflight_physical(MultiFab& state, const Geometry& geometry, + CommunicatorView communicator) const { + return preflight_physical_impl_(state, geometry.domain, &geometry, Real(0), {}, false, + communicator); + } + + PhysicalFillPreflight preflight_physical(MultiFab& state, const Geometry& geometry, + Real physical_time, std::string_view clock) const { + return preflight_physical(state, geometry, physical_time, clock, world_communicator_view()); + } + + PhysicalFillPreflight preflight_physical(MultiFab& state, const Geometry& geometry, + Real physical_time, std::string_view clock, + CommunicatorView communicator) const { + return preflight_physical_impl_(state, geometry.domain, &geometry, physical_time, clock, true, + communicator); + } + + /// Consume one exact preflight after the owning PreparedBoundaryPlan has produced native halos. + /// No validation or host allocation remains on this commit path. + void fill_physical_preflighted(MultiFab& state, PhysicalFillPreflight&& preflight) const { + if (preflight.owner_ != this || preflight.state_ != &state || + preflight.ncomp_ != state.ncomp() || preflight.depth_ != state.n_grow()) + throw std::logic_error( + "prepared hyperbolic boundary received a foreign or stale physical preflight"); + preflight.owner_ = nullptr; + fill_physical_preflighted_impl_(state, preflight.domain_, + preflight.has_geometry_ ? &preflight.geometry_ : nullptr, + preflight.physical_time_); + } + + private: + PhysicalFillPreflight preflight_physical_impl_(MultiFab& state, const Box2D& domain, + const Geometry* geometry, Real physical_time, + std::string_view clock, bool has_evaluation_point, + CommunicatorView communicator) const { + static_assert(Dim == 2, "the current MultiFab storage is two-dimensional"); + if (requires_fixed_state_conversion()) + throw std::logic_error( + "primitive fixed-state boundary reached execution before model conversion"); + if (state.ncomp() != ncomp()) + throw std::invalid_argument( + "prepared hyperbolic boundary component count differs from the state"); + const int depth = state.n_grow(); + if (depth == 0) + return PhysicalFillPreflight(this, &state, domain, geometry, physical_time); + if (has_analytic_state()) { + if (geometry == nullptr || geometry->domain != domain) + throw std::invalid_argument( + "analytic hyperbolic boundary requires matching physical Geometry"); + for (int face = 0; face < 2 * Dim; ++face) { + const auto& prepared = faces_[static_cast(face)]; + if (prepared.analytic_state.empty()) + continue; + const int axis_cells = face / 2 == 0 ? domain.nx() : domain.ny(); + if (depth > axis_cells) + throw std::invalid_argument( + "analytic hyperbolic boundary does not support multi-reflection ghost depth"); + if (!prepared.analytic_clock.empty() && + (!has_evaluation_point || prepared.analytic_clock != clock || + !std::isfinite(physical_time))) + throw std::invalid_argument( + "analytic hyperbolic boundary requires its exact finite BoundaryEvaluationPoint"); + } + validate_analytic_values_(state, *geometry, physical_time, communicator); + } + const auto table = table_view(); + for (int component = 0; component < ncomp(); ++component) { + for (int offset = 1; offset <= depth; ++offset) { + if (detail::is_physical_hyperbolic_law(faces_[0].law)) + detail::validate_hyperbolic_extension(domain.lo[0] - offset, domain.lo[0], domain.hi[0], + 0, faces_[0].law, faces_[1].law, table, component); + if (detail::is_physical_hyperbolic_law(faces_[1].law)) + detail::validate_hyperbolic_extension(domain.hi[0] + offset, domain.lo[0], domain.hi[0], + 0, faces_[0].law, faces_[1].law, table, component); + if (detail::is_physical_hyperbolic_law(faces_[2].law)) + detail::validate_hyperbolic_extension(domain.lo[1] - offset, domain.lo[1], domain.hi[1], + 1, faces_[2].law, faces_[3].law, table, component); + if (detail::is_physical_hyperbolic_law(faces_[3].law)) + detail::validate_hyperbolic_extension(domain.hi[1] + offset, domain.lo[1], domain.hi[1], + 1, faces_[2].law, faces_[3].law, table, component); + } + } + return PhysicalFillPreflight(this, &state, domain, geometry, physical_time); + } + + void fill_physical_preflighted_impl_(MultiFab& state, const Box2D& domain, + const Geometry* geometry, Real physical_time) const { + const int depth = state.n_grow(); + if (depth == 0) + return; + const auto table = table_view(); + for (int local = 0; local < state.local_size(); ++local) { + Fab2D& fab = state.fab(local); + const Box2D valid = fab.box(); + const Array4 values = fab.array(); + + int tangential_lo = valid.lo[1] - depth; + int tangential_hi = valid.hi[1] + depth; + if (faces_[2].law != HyperbolicBoundaryLaw::Periodic) + tangential_lo = std::max(tangential_lo, domain.lo[1]); + if (faces_[3].law != HyperbolicBoundaryLaw::Periodic) + tangential_hi = std::min(tangential_hi, domain.hi[1]); + if (detail::is_physical_hyperbolic_law(faces_[0].law) && valid.lo[0] == domain.lo[0]) + fill_x_face_( + values, Box2D{{domain.lo[0] - depth, tangential_lo}, {domain.lo[0] - 1, tangential_hi}}, + 0, domain, geometry, physical_time, table); + if (detail::is_physical_hyperbolic_law(faces_[1].law) && valid.hi[0] == domain.hi[0]) + fill_x_face_( + values, Box2D{{domain.hi[0] + 1, tangential_lo}, {domain.hi[0] + depth, tangential_hi}}, + 1, domain, geometry, physical_time, table); + + tangential_lo = valid.lo[0] - depth; + tangential_hi = valid.hi[0] + depth; + if (faces_[0].law != HyperbolicBoundaryLaw::Periodic) + tangential_lo = std::max(tangential_lo, domain.lo[0]); + if (faces_[1].law != HyperbolicBoundaryLaw::Periodic) + tangential_hi = std::min(tangential_hi, domain.hi[0]); + if (detail::is_physical_hyperbolic_law(faces_[2].law) && valid.lo[1] == domain.lo[1]) + fill_y_face_( + values, Box2D{{tangential_lo, domain.lo[1] - depth}, {tangential_hi, domain.lo[1] - 1}}, + 2, domain, geometry, physical_time, table); + if (detail::is_physical_hyperbolic_law(faces_[3].law) && valid.hi[1] == domain.hi[1]) + fill_y_face_( + values, Box2D{{tangential_lo, domain.hi[1] + 1}, {tangential_hi, domain.hi[1] + depth}}, + 3, domain, geometry, physical_time, table); + } + } + + std::array faces_{}; + std::vector component_transforms_; + HyperbolicCornerPolicy corner_policy_ = HyperbolicCornerPolicy::NotRequired; + bool explicit_periodic_identifications_ = false; +#if defined(POPS_HAS_KOKKOS) + Kokkos::View device_transforms_; + Kokkos::View device_fixed_values_; +#else + std::vector device_transforms_; + std::vector device_fixed_values_; +#endif + + template + detail::AnalyticFixedFaceEvaluator analytic_evaluator_(int face_ordinal, int component, + const Geometry& geometry, + Real physical_time) const { + const auto& face = faces_[static_cast(face_ordinal)]; + const bool periodic_tangent = Axis == 0 ? faces_[2].law == HyperbolicBoundaryLaw::Periodic + : faces_[0].law == HyperbolicBoundaryLaw::Periodic; + return {face.analytic_state[static_cast(component)].view(), geometry, + face_ordinal % 2 == 0 ? -1 : 1, periodic_tangent, physical_time}; + } + + void fill_x_face_(const Array4& values, const Box2D& region, int face_ordinal, + const Box2D& domain, const Geometry* geometry, Real physical_time, + const detail::HyperbolicBoundaryTableView& table) const { + const auto& face = faces_[static_cast(face_ordinal)]; + if (face.analytic_state.empty()) { + for_each_cell(region, + detail::HyperbolicFaceXKernel{values, table, domain.lo[0], domain.hi[0], + faces_[0].law, faces_[1].law}); + return; + } + if (geometry == nullptr) + throw std::logic_error("analytic x-face execution lost physical Geometry"); + for (int component = 0; component < ncomp(); ++component) + for_each_cell(region, + detail::AnalyticFixedFaceKernel<0>{ + values, component, + analytic_evaluator_<0>(face_ordinal, component, *geometry, physical_time)}); + } + + void fill_y_face_(const Array4& values, const Box2D& region, int face_ordinal, + const Box2D& domain, const Geometry* geometry, Real physical_time, + const detail::HyperbolicBoundaryTableView& table) const { + const auto& face = faces_[static_cast(face_ordinal)]; + if (face.analytic_state.empty()) { + for_each_cell(region, + detail::HyperbolicFaceYKernel{values, table, domain.lo[1], domain.hi[1], + faces_[2].law, faces_[3].law}); + return; + } + if (geometry == nullptr) + throw std::logic_error("analytic y-face execution lost physical Geometry"); + for (int component = 0; component < ncomp(); ++component) + for_each_cell(region, + detail::AnalyticFixedFaceKernel<1>{ + values, component, + analytic_evaluator_<1>(face_ordinal, component, *geometry, physical_time)}); + } + + void validate_analytic_values_(const MultiFab& state, const Geometry& geometry, + Real physical_time, CommunicatorView communicator) const { + const int depth = state.n_grow(); + long invalid_local = 0; + for (int local = 0; local < state.local_size(); ++local) { + const Box2D valid = state.fab(local).box(); + int tangential_lo = valid.lo[1] - depth; + int tangential_hi = valid.hi[1] + depth; + if (faces_[2].law != HyperbolicBoundaryLaw::Periodic) + tangential_lo = std::max(tangential_lo, geometry.domain.lo[1]); + if (faces_[3].law != HyperbolicBoundaryLaw::Periodic) + tangential_hi = std::min(tangential_hi, geometry.domain.hi[1]); + for (int face_ordinal = 0; face_ordinal < 2; ++face_ordinal) { + const auto& face = faces_[static_cast(face_ordinal)]; + const bool touches = face_ordinal == 0 ? valid.lo[0] == geometry.domain.lo[0] + : valid.hi[0] == geometry.domain.hi[0]; + if (face.analytic_state.empty() || !touches) + continue; + const Box2D region = face_ordinal == 0 + ? Box2D{{geometry.domain.lo[0] - depth, tangential_lo}, + {geometry.domain.lo[0] - 1, tangential_hi}} + : Box2D{{geometry.domain.hi[0] + 1, tangential_lo}, + {geometry.domain.hi[0] + depth, tangential_hi}}; + for (int component = 0; component < ncomp(); ++component) + invalid_local += static_cast(for_each_cell_reduce_sum( + region, detail::AnalyticFixedFaceFiniteKernel<0>{analytic_evaluator_<0>( + face_ordinal, component, geometry, physical_time)})); + } + + tangential_lo = valid.lo[0] - depth; + tangential_hi = valid.hi[0] + depth; + if (faces_[0].law != HyperbolicBoundaryLaw::Periodic) + tangential_lo = std::max(tangential_lo, geometry.domain.lo[0]); + if (faces_[1].law != HyperbolicBoundaryLaw::Periodic) + tangential_hi = std::min(tangential_hi, geometry.domain.hi[0]); + for (int face_ordinal = 2; face_ordinal < 4; ++face_ordinal) { + const auto& face = faces_[static_cast(face_ordinal)]; + const bool touches = face_ordinal == 2 ? valid.lo[1] == geometry.domain.lo[1] + : valid.hi[1] == geometry.domain.hi[1]; + if (face.analytic_state.empty() || !touches) + continue; + const Box2D region = face_ordinal == 2 + ? Box2D{{tangential_lo, geometry.domain.lo[1] - depth}, + {tangential_hi, geometry.domain.lo[1] - 1}} + : Box2D{{tangential_lo, geometry.domain.hi[1] + 1}, + {tangential_hi, geometry.domain.hi[1] + depth}}; + for (int component = 0; component < ncomp(); ++component) + invalid_local += static_cast(for_each_cell_reduce_sum( + region, detail::AnalyticFixedFaceFiniteKernel<1>{analytic_evaluator_<1>( + face_ordinal, component, geometry, physical_time)})); + } + } + const long invalid = all_reduce_sum(invalid_local, communicator); + if (invalid != 0) + throw std::runtime_error("analytic hyperbolic boundary produced non-finite values (count=" + + std::to_string(invalid) + ")"); + } + + void validate(bool explicit_periodic_identifications) const { + if (component_transforms_.empty()) + throw std::invalid_argument( + "prepared hyperbolic boundary requires model-qualified components"); + if (corner_policy_ != HyperbolicCornerPolicy::NotRequired) + throw std::invalid_argument("unsupported hyperbolic corner policy"); + for (int axis = 0; axis < Dim; ++axis) { + const auto& low = faces_[static_cast(2 * axis)]; + const auto& high = faces_[static_cast(2 * axis + 1)]; + if (!explicit_periodic_identifications && (low.law == HyperbolicBoundaryLaw::Periodic) != + (high.law == HyperbolicBoundaryLaw::Periodic)) + throw std::invalid_argument( + "prepared hyperbolic periodic topology requires complete axis pairs"); + } + for (int face_ordinal = 0; face_ordinal < 2 * Dim; ++face_ordinal) { + const auto& prepared_face = faces_[static_cast(face_ordinal)]; + if (prepared_face.identity.empty() || prepared_face.identity_token == 0) + throw std::invalid_argument("prepared hyperbolic faces require owner-qualified identities"); + if (!prepared_face.analytic_state.empty()) { + if (prepared_face.law != HyperbolicBoundaryLaw::FixedState || + prepared_face.authored_representation != HyperbolicStateRepresentation::Conservative || + !prepared_face.converter_identity.empty() || !prepared_face.fixed_state_converted || + prepared_face.analytic_state.size() != component_transforms_.size() || + std::any_of(prepared_face.fixed_state.begin(), prepared_face.fixed_state.end(), + [](Real value) { return value != Real(0); }) || + std::any_of(prepared_face.analytic_state.begin(), prepared_face.analytic_state.end(), + [](const analytic::AnalyticProgram& program) { + return program.empty() || + program.result_type() != analytic::AnalyticValueType::Scalar; + })) + throw std::invalid_argument( + "analytic hyperbolic boundary requires zero fixed-state placeholders and one " + "conservative scalar program per component"); + } else if (!prepared_face.analytic_clock.empty()) { + throw std::invalid_argument( + "only an analytic hyperbolic boundary may carry a logical Clock"); + } + if (prepared_face.law == HyperbolicBoundaryLaw::FixedState) { + if (prepared_face.fixed_state.size() != component_transforms_.size() || + std::any_of(prepared_face.fixed_state.begin(), prepared_face.fixed_state.end(), + [](Real value) { return !std::isfinite(value); })) + throw std::invalid_argument( + "fixed-state hyperbolic boundary must provide one finite value per component"); + if (prepared_face.authored_representation == HyperbolicStateRepresentation::Primitive) { + if (prepared_face.converter_identity.empty()) + throw std::invalid_argument( + "primitive fixed-state boundary requires one converter identity"); + } else if (!prepared_face.converter_identity.empty() || + !prepared_face.fixed_state_converted) { + throw std::invalid_argument( + "conservative fixed-state boundary must not carry conversion metadata"); + } + } else if (!prepared_face.fixed_state.empty()) { + throw std::invalid_argument( + "only a fixed-state hyperbolic boundary may carry component values"); + } else if (prepared_face.authored_representation != + HyperbolicStateRepresentation::Conservative || + !prepared_face.converter_identity.empty() || + !prepared_face.fixed_state_converted) { + throw std::invalid_argument( + "only a fixed-state hyperbolic boundary may carry conversion metadata"); + } + if (prepared_face.law == HyperbolicBoundaryLaw::ReflectiveSlip) { + const int normal_axis = face_ordinal / 2; + const bool owns_normal_polar_component = + std::any_of(component_transforms_.begin(), component_transforms_.end(), + [normal_axis](const Transform& transform) { + return transform.parity == HyperbolicComponentParity::PolarVector && + transform.axis == normal_axis; + }); + if (!owns_normal_polar_component) + throw std::invalid_argument( + "reflective slip wall requires a declared normal polar-vector component"); + } + } + } + + void prepare_device_tables() { + const std::size_t components = component_transforms_.size(); + std::vector fixed(static_cast(2 * Dim) * components, Real(0)); + for (int face_ordinal = 0; face_ordinal < 2 * Dim; ++face_ordinal) { + const auto& source = faces_[static_cast(face_ordinal)].fixed_state; + if (source.empty()) + continue; + std::copy(source.begin(), source.end(), + fixed.begin() + static_cast(face_ordinal * components)); + } +#if defined(POPS_HAS_KOKKOS) + detail::ensure_kokkos_initialized(); + device_transforms_ = + Kokkos::View("pops_boundary_transforms", components); + device_fixed_values_ = + Kokkos::View("pops_boundary_fixed_values", fixed.size()); + auto host_transforms = Kokkos::create_mirror_view(device_transforms_); + auto host_fixed = Kokkos::create_mirror_view(device_fixed_values_); + for (std::size_t index = 0; index < components; ++index) + host_transforms(index) = component_transforms_[index]; + for (std::size_t index = 0; index < fixed.size(); ++index) + host_fixed(index) = fixed[index]; + Kokkos::deep_copy(device_transforms_, host_transforms); + Kokkos::deep_copy(device_fixed_values_, host_fixed); +#else + device_transforms_ = component_transforms_; + device_fixed_values_ = std::move(fixed); +#endif + } + + detail::HyperbolicBoundaryTableView table_view() const { + return { + device_transforms_.data(), + device_fixed_values_.data(), + ncomp(), + }; + } +}; + +/// Sole built-in parser from the installed Python/native table into the typed hyperbolic plan. +template +PreparedHyperbolicBoundary prepare_hyperbolic_boundary( + const std::vector& face_types, const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, bool explicit_periodic_identifications = false, + const std::vector& face_representations = {}, + const std::vector& face_converter_identities = {}, + const std::vector>& face_analytic_opcodes = {}, + const std::vector>& face_analytic_literals = {}, + const std::vector& face_analytic_clocks = {}) { + if (face_types.size() != static_cast(2 * Dim) || + face_identities.size() != static_cast(2 * Dim)) + throw std::invalid_argument( + "prepared hyperbolic boundary requires one type and identity per oriented face"); + if (component_roles.empty() || + face_values.size() != component_roles.size() * static_cast(2 * Dim)) + throw std::invalid_argument( + "prepared hyperbolic boundary values must be component-major and total"); + if ((!face_representations.empty() && + face_representations.size() != static_cast(2 * Dim)) || + (!face_converter_identities.empty() && + face_converter_identities.size() != static_cast(2 * Dim))) + throw std::invalid_argument( + "prepared hyperbolic boundary conversion metadata must cover every oriented face"); + const std::size_t analytic_rows = static_cast(2 * Dim) * component_roles.size(); + if (face_analytic_opcodes.empty() != face_analytic_literals.empty() || + (!face_analytic_opcodes.empty() && + (face_analytic_opcodes.size() != analytic_rows || + face_analytic_literals.size() != analytic_rows || + face_analytic_clocks.size() != static_cast(2 * Dim))) || + (face_analytic_opcodes.empty() && !face_analytic_clocks.empty())) + throw std::invalid_argument( + "prepared hyperbolic analytic tables must cover every face/component and Clock"); + + std::vector> transforms; + transforms.reserve(component_roles.size()); + for (const auto& role : component_roles) + transforms.push_back(detail::transform_from_role(role)); + + std::string plan_analytic_clock; + std::array faces; + for (int face = 0; face < 2 * Dim; ++face) { + auto& destination = faces[static_cast(face)]; + destination.law = detail::hyperbolic_law_from_token(face_types[static_cast(face)]); + destination.identity = face_identities[static_cast(face)]; + destination.identity_token = detail::stable_boundary_identity(destination.identity); + destination.authored_representation = detail::hyperbolic_representation_from_token( + face_representations.empty() + ? std::string_view("conservative") + : std::string_view(face_representations[static_cast(face)])); + destination.converter_identity = + face_converter_identities.empty() + ? std::string{} + : face_converter_identities[static_cast(face)]; + if (destination.law == HyperbolicBoundaryLaw::FixedState) { + destination.fixed_state.reserve(component_roles.size()); + for (std::size_t component = 0; component < component_roles.size(); ++component) + destination.fixed_state.push_back( + static_cast(face_values[component * static_cast(2 * Dim) + + static_cast(face)])); + destination.fixed_state_converted = + destination.authored_representation == HyperbolicStateRepresentation::Conservative; + } + if (!face_analytic_opcodes.empty()) { + bool any_program = false; + bool every_program = true; + bool reads_time = false; + destination.analytic_clock = face_analytic_clocks[static_cast(face)]; + for (std::size_t component = 0; component < component_roles.size(); ++component) { + const std::size_t row = static_cast(face) * component_roles.size() + component; + const auto& opcodes = face_analytic_opcodes[row]; + const auto& literals = face_analytic_literals[row]; + any_program = any_program || !opcodes.empty(); + every_program = every_program && !opcodes.empty(); + if (opcodes.empty() && literals.empty()) + continue; + if (opcodes.empty() || opcodes.size() != literals.size()) + throw std::invalid_argument( + "prepared hyperbolic analytic opcode/literal rows must be non-empty and aligned"); + std::vector tokens; + tokens.reserve(opcodes.size()); + for (std::size_t index = 0; index < opcodes.size(); ++index) { + const analytic::AnalyticOp op = analytic::analytic_op_from_name(opcodes[index]); + const double raw = literals[index]; + if (!std::isfinite(raw)) + throw std::invalid_argument( + "prepared hyperbolic analytic token literal must be finite"); + if (op == analytic::AnalyticOp::Input) { + if (raw != 0.0) + throw std::invalid_argument( + "prepared hyperbolic analytic input is reserved for physical time slot zero"); + reads_time = true; + } + tokens.push_back({op, static_cast(raw)}); + } + destination.analytic_state.push_back(analytic::compile_analytic_postfix(tokens)); + } + if (any_program != every_program) + throw std::invalid_argument( + "prepared hyperbolic analytic face must cover every state component"); + if (!any_program) { + if (!destination.analytic_clock.empty()) + throw std::invalid_argument( + "prepared hyperbolic analytic Clock requires a program on the same face"); + destination.analytic_clock.clear(); + } else if (reads_time != !destination.analytic_clock.empty()) { + throw std::invalid_argument( + "prepared hyperbolic analytic physical-time input requires one exact logical Clock"); + } else if (reads_time) { + if (plan_analytic_clock.empty()) + plan_analytic_clock = destination.analytic_clock; + else if (plan_analytic_clock != destination.analytic_clock) + throw std::invalid_argument( + "prepared hyperbolic analytic plan cannot mix logical Clocks"); + } + } + } + return PreparedHyperbolicBoundary(std::move(faces), std::move(transforms), + HyperbolicCornerPolicy::NotRequired, + explicit_periodic_identifications); +} + +} // namespace pops diff --git a/include/pops/numerics/fv/flux_failure.hpp b/include/pops/numerics/fv/flux_failure.hpp index 35c629142..cb7b1c1f3 100644 --- a/include/pops/numerics/fv/flux_failure.hpp +++ b/include/pops/numerics/fv/flux_failure.hpp @@ -1,16 +1,17 @@ #pragma once /// @file -/// @brief Device-to-host failure channel for pointwise numerical-flux evaluations. +/// @brief Device-to-host failure channel for pointwise face-state and numerical-flux evaluations. /// -/// Flux providers execute inside Kokkos kernels and therefore cannot throw. Every spatial -/// entrypoint owns one tracker, passes its trivially-copyable recorder to all of its kernels, and -/// consumes the collective report before publishing the computed field. The packed reduction is -/// ordered first by status severity (Ok < Retry < Reject < Failed), then by the unsigned reason -/// code. Consequently concurrent failures produce one deterministic result on every backend and, -/// after the world reduction, on every MPI rank. +/// Primitive recovery and flux providers execute inside Kokkos kernels and therefore cannot throw. +/// Every spatial entrypoint owns one tracker, passes its trivially-copyable recorder to all of its +/// kernels, and consumes the collective report before publishing the computed field. The packed +/// reduction is ordered first by status severity (Ok < Retry < Reject < Failed), then by the +/// unsigned reason code. Consequently concurrent failures produce one deterministic result on +/// every backend and, after the world reduction, on every MPI rank. #include +#include #include #include @@ -116,6 +117,28 @@ namespace detail { inline constexpr std::uint64_t kFluxReasonMask = UINT64_C(0xffffffff); inline constexpr int kFluxSeverityShift = 32; inline constexpr std::uint32_t kNonFiniteFiniteVolumeReason = UINT32_C(0x4e46494e); // "NFIN" +inline constexpr std::uint32_t kVariableRecoveryReasonBase = UINT32_C(0x56520000); // "VR" + +POPS_HD constexpr EvaluationStatus recovery_evaluation_status(RecoveryStatus status) { + switch (status) { + case RecoveryStatus::kRecovered: + return EvaluationStatus::kOk; + case RecoveryStatus::kExhausted: + return EvaluationStatus::kRetry; + case RecoveryStatus::kRejected: + return EvaluationStatus::kReject; + case RecoveryStatus::kInvalidContract: + return EvaluationStatus::kFailed; + } + return EvaluationStatus::kFailed; +} + +POPS_HD constexpr std::uint32_t recovery_evaluation_reason(const RecoveryReport& report) { + if (report.reason_code != 0) + return report.reason_code; + return kVariableRecoveryReasonBase | + (static_cast(report.cause) & UINT32_C(0x0000ffff)); +} POPS_HD constexpr std::uint64_t pack_flux_failure(EvaluationStatus status, std::uint32_t reason_code) { @@ -170,6 +193,20 @@ struct FluxEvaluationRecorder { if (candidate > aggregate) aggregate = candidate; } + + /// Join one device-side primitive/local-variable recovery refusal into the same deterministic + /// transport reduction as numerical-flux failures. Recovery is computed pointwise and cannot + /// throw from a Kokkos kernel; this adapter preserves Retry/Reject/Fatal semantics and the + /// provider reason code without allocating or invoking a type-erased callback. + POPS_HD void record_recovery(const RecoveryReport& report, std::uint64_t& aggregate) const { + if (report.publication_permitted()) + return; + const std::uint64_t candidate = + detail::pack_flux_failure(detail::recovery_evaluation_status(report.status), + detail::recovery_evaluation_reason(report)); + if (candidate > aggregate) + aggregate = candidate; + } }; /// Explicit authority for the current transport scheduler's process-world collective order. diff --git a/include/pops/numerics/fv/flux_interfaces.hpp b/include/pops/numerics/fv/flux_interfaces.hpp index 0265fad9f..b8af033f7 100644 --- a/include/pops/numerics/fv/flux_interfaces.hpp +++ b/include/pops/numerics/fv/flux_interfaces.hpp @@ -10,9 +10,11 @@ #include #include +#include #include #include #include +#include namespace pops { @@ -96,12 +98,38 @@ struct QualifiedProviderRequirement { const char* layout; const char* value_kind; const char* producer; + bool available; int storage_slot; }; enum class EvaluationStatus : std::uint8_t { kOk, kRetry, kReject, kFailed }; enum class TransactionFailureAction : std::uint8_t { kNone, kRetryStep, kRejectStep, kAbortRun }; +/// Stable, device-copyable causes emitted by the built-in Riemann candidates. +/// +/// External numerical-flux providers may retain their own qualified reason codes. Built-ins use +/// this enum instead of scattering untyped literals through face kernels, so one rejected candidate +/// remains attributable after device/MPI reduction and step-transaction rollback. +enum class RiemannFailureCause : std::uint32_t { + kRusanovInvalidStability = UINT32_C(0x53544201), + kHllInvalidWaveInterval = UINT32_C(0x484c4c01), + kHllInvalidStability = UINT32_C(0x53544202), + kHllcInvalidWaveInterval = UINT32_C(0x484c4c02), + kHllcInvalidStability = UINT32_C(0x53544203), + kHllcNonFinitePhysicalFlux = UINT32_C(0x484c4301), + kHllcNonFinitePressure = UINT32_C(0x484c4302), + kHllcNonFiniteContact = UINT32_C(0x484c4303), + kHllcNonFiniteStarState = UINT32_C(0x484c4304), + kHllcNonFiniteFlux = UINT32_C(0x484c4305), + kRoeInvalidStability = UINT32_C(0x53544204), + kRoeNonFiniteDissipation = UINT32_C(0x524f4501), + kRoeNonFiniteFlux = UINT32_C(0x524f4502), +}; + +POPS_HD constexpr std::uint32_t riemann_reason_code(RiemannFailureCause cause) { + return static_cast(cause); +} + POPS_HD constexpr TransactionFailureAction transaction_action(EvaluationStatus status) { switch (status) { case EvaluationStatus::kOk: @@ -126,6 +154,46 @@ inline constexpr int flux_provider_count = [] { return kAuxBaseComps; }(); +template +inline constexpr bool has_qualified_flux_provider_requirements = requires { + Model::n_flux_providers; + Model::flux_provider_requirements; +}; + +/// Authenticate the generated logical provider ABI before a device pack can be instantiated. +/// +/// Hand-written C++ test models may omit both members. Generated models must provide both, and +/// every selected provider must be available, fully qualified, and backed by one in-range native +/// storage slot. The binder consumes exactly these rows; they are not inspection-only metadata. +template +consteval bool qualified_flux_provider_requirements_valid() { + constexpr bool has_count = requires { Model::n_flux_providers; }; + constexpr bool has_rows = requires { Model::flux_provider_requirements; }; + if constexpr (has_count != has_rows) { + return false; + } else if constexpr (!has_count) { + return true; + } else { + if (Model::n_flux_providers < 0 || static_cast(Model::n_flux_providers) != + Model::flux_provider_requirements.size()) + return false; + const auto nonempty = [](const char* value) { return value != nullptr && value[0] != '\0'; }; + for (std::size_t index = 0; index < Model::flux_provider_requirements.size(); ++index) { + const auto& row = Model::flux_provider_requirements[index]; + if (!row.available || row.storage_slot < 0 || + row.storage_slot >= flux_provider_count || !nonempty(row.owner_qid) || + !nonempty(row.space_kind) || !nonempty(row.space_name) || !nonempty(row.component) || + !nonempty(row.representation) || !nonempty(row.centering) || !nonempty(row.layout) || + !nonempty(row.producer)) + return false; + for (std::size_t previous = 0; previous < index; ++previous) + if (Model::flux_provider_requirements[previous].storage_slot == row.storage_slot) + return false; + } + return true; + } +} + /// Exact, model-qualified values before they are sealed into a bound device pack. /// /// Unlike the historical global Aux object this type has exactly the width requested by Model. @@ -135,6 +203,8 @@ inline constexpr int flux_provider_count = [] { template struct FluxProviderValues { static constexpr int size = flux_provider_count; + static_assert(qualified_flux_provider_requirements_valid(), + "generated physical flux provider requirements are invalid"); static_assert(size >= kAuxBaseComps, "physical flux provider packs must declare the required base providers"); static_assert(size <= kAuxMaxComps, @@ -175,15 +245,43 @@ POPS_HD BoundFluxProviders bind_flux_providers(const FluxProviderValues(values); } +namespace detail { + +template +inline constexpr int qualified_flux_provider_storage_slot = + Model::flux_provider_requirements[Index].storage_slot; + +template +POPS_HD BoundFluxProviders bind_qualified_flux_providers_at( + const Storage& storage, int i, int j, std::index_sequence) { + FluxProviderValues values{}; + ((values[qualified_flux_provider_storage_slot] = + storage(i, j, qualified_flux_provider_storage_slot)), + ...); + return bind_flux_providers(values); +} + +} // namespace detail + /// Bind one exact provider pack directly from native field storage. The caller supplies a /// model-qualified component count at compile time; there is no global Aux object, truncation, or /// zero-on-missing branch on this path. template POPS_HD BoundFluxProviders bind_flux_providers_at(const Storage& storage, int i, int j) { - FluxProviderValues values{}; - for (int component = 0; component < FluxProviderValues::size; ++component) - values[component] = storage(i, j, component); - return bind_flux_providers(values); + if constexpr (has_qualified_flux_provider_requirements) { + static_assert(qualified_flux_provider_requirements_valid(), + "generated physical flux provider requirements are invalid"); + constexpr std::size_t count = qualified_flux_provider_requirements_valid() + ? static_cast(Model::n_flux_providers) + : 0; + return detail::bind_qualified_flux_providers_at(storage, i, j, + std::make_index_sequence{}); + } else { + FluxProviderValues values{}; + for (int component = 0; component < FluxProviderValues::size; ++component) + values[component] = storage(i, j, component); + return bind_flux_providers(values); + } } template @@ -229,6 +327,9 @@ struct FluxEvaluation { POPS_HD static FluxEvaluation reject(std::uint32_t reason) { return FluxEvaluation(EvaluationStatus::kReject, {}, reason, invalid_density()); } + POPS_HD static FluxEvaluation reject(RiemannFailureCause cause) { + return reject(riemann_reason_code(cause)); + } POPS_HD static FluxEvaluation failed(std::uint32_t reason) { return FluxEvaluation(EvaluationStatus::kFailed, {}, reason, invalid_density()); } diff --git a/include/pops/numerics/fv/numerical_flux.hpp b/include/pops/numerics/fv/numerical_flux.hpp index 657b6390b..8b743ca48 100644 --- a/include/pops/numerics/fv/numerical_flux.hpp +++ b/include/pops/numerics/fv/numerical_flux.hpp @@ -53,6 +53,14 @@ POPS_HD inline bool valid_hll_speed_interval(Real lower, Real upper) { return Kokkos::isfinite(lower) && Kokkos::isfinite(upper) && lower <= upper; } +template +POPS_HD inline bool finite_state(const State& state) { + for (int component = 0; component < State::size(); ++component) + if (!Kokkos::isfinite(state[component])) + return false; + return true; +} + /// Union two independently certified signed-wave-speed intervals. Validate both traces before /// min/max: IEEE comparisons with NaN are false, so taking the union first could silently discard /// an invalid left or right trace and manufacture a plausible finite HLL interval. @@ -81,7 +89,8 @@ struct RusanovFlux { StabilityBound bound{}; if (!detail::max_normal_stability_bound(physical.stability(left, face), physical.stability(right, face), bound)) - return FluxEvaluation::reject(0x53544201u); + return FluxEvaluation::reject( + RiemannFailureCause::kRusanovInvalidStability); const auto left_density = physical.evaluate(left, face); const auto right_density = physical.evaluate(right, face); typename Physical::State density{}; @@ -113,11 +122,13 @@ POPS_HD FluxEvaluation hll_flux_with_speeds( const Physical& physical, const typename Physical::Trace& left, const typename Physical::Trace& right, const FaceContext& face, Real lower, Real upper) { if (!detail::valid_hll_speed_interval(lower, upper)) - return FluxEvaluation::reject(0x484c4c01u); + return FluxEvaluation::reject( + RiemannFailureCause::kHllInvalidWaveInterval); StabilityBound bound{}; if (!detail::max_normal_stability_bound(physical.stability(left, face), physical.stability(right, face), bound)) - return FluxEvaluation::reject(0x53544202u); + return FluxEvaluation::reject( + RiemannFailureCause::kHllInvalidStability); const auto left_density = physical.evaluate(left, face); const auto right_density = physical.evaluate(right, face); if (lower >= Real(0)) @@ -186,33 +197,61 @@ struct HLLCFlux { Real lower, upper; hll_speeds(physical, left, right, face, lower, upper); if (!detail::valid_hll_speed_interval(lower, upper)) - return FluxEvaluation::reject(0x484c4c02u); + return FluxEvaluation::reject( + RiemannFailureCause::kHllcInvalidWaveInterval); StabilityBound bound{}; if (!detail::max_normal_stability_bound(physical.stability(left, face), physical.stability(right, face), bound)) - return FluxEvaluation::reject(0x53544203u); + return FluxEvaluation::reject( + RiemannFailureCause::kHllcInvalidStability); const auto left_density = physical.evaluate(left, face); const auto right_density = physical.evaluate(right, face); - if (lower >= Real(0)) + if (lower >= Real(0)) { + if (!detail::finite_state(left_density.value)) + return FluxEvaluation::reject( + RiemannFailureCause::kHllcNonFinitePhysicalFlux); return FluxEvaluation::ok(left_density.value, bound); - if (upper <= Real(0)) + } + if (upper <= Real(0)) { + if (!detail::finite_state(right_density.value)) + return FluxEvaluation::reject( + RiemannFailureCause::kHllcNonFinitePhysicalFlux); return FluxEvaluation::ok(right_density.value, bound); + } + if (!detail::finite_state(left_density.value) || !detail::finite_state(right_density.value)) + return FluxEvaluation::reject( + RiemannFailureCause::kHllcNonFinitePhysicalFlux); const Real pressure_left = physical.pressure(left.state); const Real pressure_right = physical.pressure(right.state); + if (!Kokkos::isfinite(pressure_left) || !Kokkos::isfinite(pressure_right)) + return FluxEvaluation::reject( + RiemannFailureCause::kHllcNonFinitePressure); const Real contact = physical.contact_speed(left.state, right.state, pressure_left, pressure_right, lower, upper, face); + if (!Kokkos::isfinite(contact)) + return FluxEvaluation::reject( + RiemannFailureCause::kHllcNonFiniteContact); typename Physical::State density{}; if (contact >= Real(0)) { const auto star = physical.star_state(left.state, pressure_left, lower, contact, face); + if (!detail::finite_state(star)) + return FluxEvaluation::reject( + RiemannFailureCause::kHllcNonFiniteStarState); for (int component = 0; component < Physical::n_vars; ++component) density[component] = left_density.value[component] + lower * (star[component] - left.state[component]); } else { const auto star = physical.star_state(right.state, pressure_right, upper, contact, face); + if (!detail::finite_state(star)) + return FluxEvaluation::reject( + RiemannFailureCause::kHllcNonFiniteStarState); for (int component = 0; component < Physical::n_vars; ++component) density[component] = right_density.value[component] + upper * (star[component] - right.state[component]); } + if (!detail::finite_state(density)) + return FluxEvaluation::reject( + RiemannFailureCause::kHllcNonFiniteFlux); return FluxEvaluation::ok(density, bound); } else { static_assert(detail::dependent_false, @@ -243,16 +282,23 @@ struct RoeFlux { StabilityBound bound{}; if (!detail::max_normal_stability_bound(physical.stability(left, face), physical.stability(right, face), bound)) - return FluxEvaluation::reject(0x53544204u); + return FluxEvaluation::reject( + RiemannFailureCause::kRoeInvalidStability); const auto left_density = physical.evaluate(left, face); const auto right_density = physical.evaluate(right, face); const auto dissipation = physical.roe_dissipation(left, right, face); + if (!detail::finite_state(dissipation)) + return FluxEvaluation::reject( + RiemannFailureCause::kRoeNonFiniteDissipation); typename Physical::State density{}; for (int component = 0; component < Physical::n_vars; ++component) { density[component] = Real(0.5) * (left_density.value[component] + right_density.value[component]) - Real(0.5) * dissipation[component]; } + if (!detail::finite_state(density)) + return FluxEvaluation::reject( + RiemannFailureCause::kRoeNonFiniteFlux); return FluxEvaluation::ok(density, bound); } else { static_assert(detail::dependent_false, diff --git a/include/pops/numerics/fv/reconstruction.hpp b/include/pops/numerics/fv/reconstruction.hpp index 8adf4af99..1a31c9686 100644 --- a/include/pops/numerics/fv/reconstruction.hpp +++ b/include/pops/numerics/fv/reconstruction.hpp @@ -84,6 +84,55 @@ struct VanLeer { } }; +/// Monotonized-central (MC) limiter: second-order TVD with two ghosts. +/// +/// For backward/forward differences a,b with the same sign this returns +/// min((|a|+|b|)/2, 2|a|, 2|b|) with their common sign, and zero otherwise. +/// The comparisons are arranged so finite inputs cannot overflow while forming either the +/// centred candidate or the doubled bound. Stateless, branch-local and POPS_HD. +struct MC { + static constexpr int formal_order = 2; + static constexpr int n_ghost = 2; + POPS_HD Real limited_slope(Real backward, Real forward) const { + const bool positive = backward > Real(0) && forward > Real(0); + const bool negative = backward < Real(0) && forward < Real(0); + if (!positive && !negative) + return Real(0); + + const Real a = positive ? backward : -backward; + const Real b = positive ? forward : -forward; + const Real centred = Real(0.5) * a + Real(0.5) * b; + const Real smaller = a < b ? a : b; + // centred <= 2*smaller without forming the potentially overflowing doubled value. + const Real magnitude = Real(0.5) * centred <= smaller ? centred : Real(2) * smaller; + return positive ? magnitude : -magnitude; + } +}; + +/// Superbee limiter: compressive second-order TVD reconstruction with two ghosts. +/// +/// For same-sign differences it evaluates +/// max(min(2|a|,|b|), min(|a|,2|b|)) with their common sign. Each doubled candidate is formed +/// only after proving it is bounded by the other finite difference, so finite inputs remain +/// finite. Stateless and POPS_HD; selection remains compile-time through the prepared registry. +struct Superbee { + static constexpr int formal_order = 2; + static constexpr int n_ghost = 2; + POPS_HD Real limited_slope(Real backward, Real forward) const { + const bool positive = backward > Real(0) && forward > Real(0); + const bool negative = backward < Real(0) && forward < Real(0); + if (!positive && !negative) + return Real(0); + + const Real a = positive ? backward : -backward; + const Real b = positive ? forward : -forward; + const Real twice_a_bounded = a <= Real(0.5) * b ? Real(2) * a : b; + const Real twice_b_bounded = b <= Real(0.5) * a ? Real(2) * b : a; + const Real magnitude = twice_a_bounded > twice_b_bounded ? twice_a_bounded : twice_b_bounded; + return positive ? magnitude : -magnitude; + } +}; + /// weno5z: WENO5-Z reconstruction (Borges 2008) at one interface, on a 5-point stencil. /// /// Returns the reconstructed value at the face BETWEEN v0 and vp1 (face +dir of cell v0). diff --git a/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp b/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp new file mode 100644 index 000000000..ab4814f86 --- /dev/null +++ b/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp @@ -0,0 +1,654 @@ +#pragma once + +/// @file +/// @brief Prepared, ordered and transactional recovery of primitive/local variables. +/// +/// A recovery plan is a compile-time chain of concrete methods. It is device-callable and owns no +/// allocation, callback registry or mutable cache. Every method returns an explicit action and +/// cause; the chain publishes only a finite candidate accepted by the plan-wide admissibility +/// provider. Warm starts and accepted state remain caller-owned and are changed only through the +/// explicit publication transaction below. + +#include +#include +#include + +#include +#include +#include + +namespace pops { + +enum class RecoveryMethodKind : int { + kUnknown = 0, + kClosedForm = 1, + kPreparedLocalNonlinear = 2, + kBracketed = 3, + kRepair = 4, + kCustom = 5, +}; + +enum class RecoveryMethodAction : int { + kCandidate = 0, + kContinueChain = 1, + kReject = 2, +}; + +enum class RecoveryStatus : int { + kRecovered = 0, + kExhausted = 1, + kRejected = 2, + kInvalidContract = 3, +}; + +enum class RecoveryCause : int { + kNone = 0, + kClosedFormUnavailable = 1, + kIterationLimit = 2, + kSingularJacobian = 3, + kInadmissibleCandidate = 4, + kSafeguardFailure = 5, + kInvalidEvaluation = 6, + kUnsupportedCapability = 7, + kEvaluationRetry = 8, + kEvaluationReject = 9, + kEvaluationFailed = 10, + kExplicitRejection = 11, + kNonFiniteCandidate = 12, + kRepairPublicationForbidden = 13, + kMissingFailureCause = 14, + kInvalidMethodAction = 15, +}; + +POPS_HD inline RecoveryCause recovery_cause_from_local_nonlinear_status( + LocalNonlinearStatus status) { + switch (status) { + case LocalNonlinearStatus::kConverged: + return RecoveryCause::kNone; + case LocalNonlinearStatus::kIterationLimit: + return RecoveryCause::kIterationLimit; + case LocalNonlinearStatus::kSingularJacobian: + return RecoveryCause::kSingularJacobian; + case LocalNonlinearStatus::kInadmissibleCandidate: + return RecoveryCause::kInadmissibleCandidate; + case LocalNonlinearStatus::kSafeguardFailure: + return RecoveryCause::kSafeguardFailure; + case LocalNonlinearStatus::kInvalidEvaluation: + return RecoveryCause::kInvalidEvaluation; + case LocalNonlinearStatus::kUnsupportedCapability: + return RecoveryCause::kUnsupportedCapability; + case LocalNonlinearStatus::kEvaluationRetry: + return RecoveryCause::kEvaluationRetry; + case LocalNonlinearStatus::kEvaluationReject: + return RecoveryCause::kEvaluationReject; + case LocalNonlinearStatus::kEvaluationFailed: + return RecoveryCause::kEvaluationFailed; + } + return RecoveryCause::kInvalidEvaluation; +} + +template +struct RecoveryMethodResult { + static_assert(N > 0, "a recovery method needs at least one variable"); + + Real value[N] = {}; + RecoveryMethodAction action = RecoveryMethodAction::kContinueChain; + RecoveryCause cause = RecoveryCause::kMissingFailureCause; + int iterations = 0; + int evaluations = 0; + Real residual_norm = std::numeric_limits::max(); + int failing_component = -1; + std::uint32_t reason_code = 0; + + POPS_HD static RecoveryMethodResult candidate(const Real (&candidate_value)[N]) { + RecoveryMethodResult result; + for (int component = 0; component < N; ++component) + result.value[component] = candidate_value[component]; + result.action = RecoveryMethodAction::kCandidate; + result.cause = RecoveryCause::kNone; + return result; + } + + POPS_HD static RecoveryMethodResult continue_chain(RecoveryCause failure) { + RecoveryMethodResult result; + result.action = RecoveryMethodAction::kContinueChain; + result.cause = failure; + return result; + } + + POPS_HD static RecoveryMethodResult reject(RecoveryCause failure) { + RecoveryMethodResult result; + result.action = RecoveryMethodAction::kReject; + result.cause = failure; + return result; + } +}; + +template +struct RecoveryOutcome { + static_assert(N > 0, "a recovery outcome needs at least one variable"); + + Real value[N] = {}; + RecoveryStatus status = RecoveryStatus::kExhausted; + RecoveryCause cause = RecoveryCause::kNone; + int attempted_methods = 0; + int selected_method = -1; + int last_method = -1; + RecoveryMethodKind selected_method_kind = RecoveryMethodKind::kUnknown; + RecoveryMethodKind last_method_kind = RecoveryMethodKind::kUnknown; + int total_iterations = 0; + int total_evaluations = 0; + Real residual_norm = std::numeric_limits::max(); + int failing_component = -1; + std::uint32_t reason_code = 0; + + POPS_HD bool recovered() const { return status == RecoveryStatus::kRecovered; } + POPS_HD bool publication_permitted() const { return recovered(); } +}; + +/// Fixed-width, type-erased summary carried across runtime/component seams. +/// +/// RecoveryOutcome keeps the recovered value at its compile-time width. Runtime block registries +/// erase that width, but must not erase the decision that controls publication. RecoveryReport is +/// therefore the exact scalar control metadata of an outcome, without a candidate buffer. +struct RecoveryReport { + RecoveryStatus status = RecoveryStatus::kExhausted; + RecoveryCause cause = RecoveryCause::kNone; + int attempted_methods = 0; + int selected_method = -1; + int last_method = -1; + RecoveryMethodKind selected_method_kind = RecoveryMethodKind::kUnknown; + RecoveryMethodKind last_method_kind = RecoveryMethodKind::kUnknown; + int total_iterations = 0; + int total_evaluations = 0; + Real residual_norm = std::numeric_limits::max(); + int failing_component = -1; + std::uint32_t reason_code = 0; + + POPS_HD bool recovered() const { return status == RecoveryStatus::kRecovered; } + POPS_HD bool publication_permitted() const { return recovered(); } +}; + +static_assert(std::is_trivially_copyable_v, + "type-erased recovery reports must remain fixed-layout copyable values"); + +template +POPS_HD inline RecoveryReport recovery_report(const RecoveryOutcome& outcome) { + return RecoveryReport{outcome.status, + outcome.cause, + outcome.attempted_methods, + outcome.selected_method, + outcome.last_method, + outcome.selected_method_kind, + outcome.last_method_kind, + outcome.total_iterations, + outcome.total_evaluations, + outcome.residual_norm, + outcome.failing_component, + outcome.reason_code}; +} + +inline constexpr const char* recovery_status_name(RecoveryStatus status) { + switch (status) { + case RecoveryStatus::kRecovered: + return "recovered"; + case RecoveryStatus::kExhausted: + return "exhausted"; + case RecoveryStatus::kRejected: + return "rejected"; + case RecoveryStatus::kInvalidContract: + return "invalid_contract"; + } + return "unknown"; +} + +inline constexpr const char* recovery_method_kind_name(RecoveryMethodKind kind) { + switch (kind) { + case RecoveryMethodKind::kUnknown: + return "unknown"; + case RecoveryMethodKind::kClosedForm: + return "closed_form"; + case RecoveryMethodKind::kPreparedLocalNonlinear: + return "prepared_local_nonlinear"; + case RecoveryMethodKind::kBracketed: + return "bracketed"; + case RecoveryMethodKind::kRepair: + return "repair"; + case RecoveryMethodKind::kCustom: + return "custom"; + } + return "unknown"; +} + +inline constexpr const char* recovery_cause_name(RecoveryCause cause) { + switch (cause) { + case RecoveryCause::kNone: + return "none"; + case RecoveryCause::kClosedFormUnavailable: + return "closed_form_unavailable"; + case RecoveryCause::kIterationLimit: + return "iteration_limit"; + case RecoveryCause::kSingularJacobian: + return "singular_jacobian"; + case RecoveryCause::kInadmissibleCandidate: + return "inadmissible_candidate"; + case RecoveryCause::kSafeguardFailure: + return "safeguard_failure"; + case RecoveryCause::kInvalidEvaluation: + return "invalid_evaluation"; + case RecoveryCause::kUnsupportedCapability: + return "unsupported_capability"; + case RecoveryCause::kEvaluationRetry: + return "evaluation_retry"; + case RecoveryCause::kEvaluationReject: + return "evaluation_reject"; + case RecoveryCause::kEvaluationFailed: + return "evaluation_failed"; + case RecoveryCause::kExplicitRejection: + return "explicit_rejection"; + case RecoveryCause::kNonFiniteCandidate: + return "non_finite_candidate"; + case RecoveryCause::kRepairPublicationForbidden: + return "repair_publication_forbidden"; + case RecoveryCause::kMissingFailureCause: + return "missing_failure_cause"; + case RecoveryCause::kInvalidMethodAction: + return "invalid_method_action"; + } + return "unknown"; +} + +struct EmptyRecoveryMethodList { + static constexpr int size = 0; + + POPS_HD constexpr RecoveryMethodKind kind_at(int) const { return RecoveryMethodKind::kUnknown; } +}; + +template +struct RecoveryMethodList { + Head head; + Tail tail; + static constexpr int size = 1 + Tail::size; + + POPS_HD constexpr RecoveryMethodKind kind_at(int index) const { + return index == 0 ? Head::kind + : (index > 0 ? tail.kind_at(index - 1) : RecoveryMethodKind::kUnknown); + } +}; + +template +POPS_HD constexpr auto recovery_methods(Head head) { + return RecoveryMethodList{head, {}}; +} + +template + requires(sizeof...(Tail) > 0) +POPS_HD constexpr auto recovery_methods(Head head, Tail... tail) { + auto prepared_tail = recovery_methods(tail...); + return RecoveryMethodList{head, prepared_tail}; +} + +template +struct PreparedVariableRecoveryPlan { + static_assert(N > 0, "a recovery plan needs at least one variable"); + static_assert(Methods::size > 0, "a recovery plan needs at least one method"); + + Admissible admissible; + Methods methods; + + POPS_HD static constexpr int method_count() { return Methods::size; } + POPS_HD constexpr RecoveryMethodKind method_kind(int index) const { + return methods.kind_at(index); + } +}; + +template +POPS_HD constexpr auto prepare_variable_recovery(Admissible admissible, Methods methods) { + return PreparedVariableRecoveryPlan{admissible, methods}; +} + +namespace recovery_detail { + +POPS_HD inline bool recovery_finite(Real value) { + return value == value && value <= std::numeric_limits::max() && + value >= -std::numeric_limits::max(); +} + +template +POPS_HD inline bool finite_vector(const Real (&value)[N], int* failing_component) { + for (int component = 0; component < N; ++component) + if (!recovery_finite(value[component])) { + if (failing_component != nullptr) + *failing_component = component; + return false; + } + if (failing_component != nullptr) + *failing_component = -1; + return true; +} + +template +POPS_HD inline void copy_vector(const Real (&source)[N], Real (&destination)[N]) { + for (int component = 0; component < N; ++component) + destination[component] = source[component]; +} + +template +POPS_HD inline void execute_recovery_chain(const EmptyRecoveryMethodList&, const Admissible&, + const Real (&)[N], const Real (&)[N], + RecoveryOutcome&) {} + +template +POPS_HD inline void execute_recovery_chain(const RecoveryMethodList& methods, + const Admissible& admissible, const Real (&conserved)[N], + const Real (&initial_guess)[N], + RecoveryOutcome& outcome) { + const RecoveryMethodResult method_result = methods.head(conserved, initial_guess); + ++outcome.attempted_methods; + outcome.last_method = MethodIndex; + outcome.last_method_kind = Head::kind; + outcome.total_iterations += method_result.iterations; + outcome.total_evaluations += method_result.evaluations; + outcome.residual_norm = method_result.residual_norm; + outcome.failing_component = method_result.failing_component; + outcome.reason_code = method_result.reason_code; + outcome.cause = method_result.cause; + + if (method_result.action == RecoveryMethodAction::kReject) { + outcome.status = method_result.cause == RecoveryCause::kNone ? RecoveryStatus::kInvalidContract + : RecoveryStatus::kRejected; + if (method_result.cause == RecoveryCause::kNone) + outcome.cause = RecoveryCause::kMissingFailureCause; + return; + } + if (method_result.action == RecoveryMethodAction::kContinueChain) { + if (method_result.cause == RecoveryCause::kNone) { + outcome.status = RecoveryStatus::kInvalidContract; + outcome.cause = RecoveryCause::kMissingFailureCause; + return; + } + execute_recovery_chain(methods.tail, admissible, conserved, initial_guess, + outcome); + return; + } + if (method_result.action != RecoveryMethodAction::kCandidate || + method_result.cause != RecoveryCause::kNone) { + outcome.status = RecoveryStatus::kInvalidContract; + outcome.cause = RecoveryCause::kInvalidMethodAction; + return; + } + + if constexpr (Head::kind == RecoveryMethodKind::kRepair) { + outcome.status = RecoveryStatus::kInvalidContract; + outcome.cause = RecoveryCause::kRepairPublicationForbidden; + return; + } + + int failing_component = -1; + if (!finite_vector(method_result.value, &failing_component)) { + outcome.status = RecoveryStatus::kInvalidContract; + outcome.cause = RecoveryCause::kNonFiniteCandidate; + outcome.failing_component = failing_component; + return; + } + if (!admissible(method_result.value, &failing_component)) { + outcome.cause = RecoveryCause::kInadmissibleCandidate; + outcome.failing_component = failing_component; + execute_recovery_chain(methods.tail, admissible, conserved, initial_guess, + outcome); + return; + } + + copy_vector(method_result.value, outcome.value); + outcome.status = RecoveryStatus::kRecovered; + outcome.cause = RecoveryCause::kNone; + outcome.selected_method = MethodIndex; + outcome.selected_method_kind = Head::kind; + outcome.failing_component = -1; +} + +} // namespace recovery_detail + +template +POPS_HD inline RecoveryOutcome recover_prepared_variable( + const PreparedVariableRecoveryPlan& plan, const Real (&conserved)[N], + const Real (&initial_guess)[N]) { + RecoveryOutcome outcome; + recovery_detail::execute_recovery_chain<0>(plan.methods, plan.admissible, conserved, + initial_guess, outcome); + return outcome; +} + +/// Admissibility provider for model conversions that impose no additional physical policy. +/// recover_prepared_variable has already rejected every non-finite component before this provider is +/// called. This preserves each model's historical closed-form conversion without adding a hidden +/// repair, floor or fallback. +template +struct FiniteModelRecoveryAdmissibility { + POPS_HD bool operator()(const Real (&)[N], int* failing_component) const { + if (failing_component != nullptr) + *failing_component = -1; + return true; + } +}; + +/// Adapter for a model-declared primitive admissibility predicate. +/// +/// The plan owns a concrete model value, so the predicate remains allocation-free and +/// device-callable. This adapter is selected only when HasRecoveryAdmissibility is true; +/// models without the optional contract retain the historical finite-only fast path above. +template +struct DeclaredModelRecoveryAdmissibility { + Model model; + + POPS_HD bool operator()(const Real (&value)[Model::n_vars], int* failing_component) const { + typename Model::Prim primitive{}; + for (int component = 0; component < Model::n_vars; ++component) + primitive[component] = value[component]; + return model.recovery_admissible(primitive, failing_component); + } +}; + +/// One declared closed-form method around the model-owned conservative -> primitive formula. +template +struct ClosedFormModelRecoveryMethod { + static constexpr RecoveryMethodKind kind = RecoveryMethodKind::kClosedForm; + Model model; + + POPS_HD RecoveryMethodResult operator()(const Real (&conserved)[Model::n_vars], + const Real (&)[Model::n_vars]) const { + typename Model::State state{}; + for (int component = 0; component < Model::n_vars; ++component) + state[component] = conserved[component]; + const typename Model::Prim primitive = model.to_primitive(state); + Real candidate[Model::n_vars] = {}; + for (int component = 0; component < Model::n_vars; ++component) + candidate[component] = primitive[component]; + return RecoveryMethodResult::candidate(candidate); + } +}; + +/// Identity is still an explicit prepared method for scalar/no-primitive models. Consequently the +/// type-erased runtime consumer receives the same failure contract for every block. +template +struct IdentityModelRecoveryMethod { + static constexpr RecoveryMethodKind kind = RecoveryMethodKind::kClosedForm; + + POPS_HD RecoveryMethodResult operator()(const Real (&conserved)[N], const Real (&)[N]) const { + return RecoveryMethodResult::candidate(conserved); + } +}; + +/// Prepare exactly one conservative -> primitive method. There is deliberately no repair or +/// fallback method in this compatibility route. +template +POPS_HD constexpr auto prepare_model_variable_recovery(const Model& model) { + constexpr int N = Model::n_vars; + if constexpr (HasPrimitiveVars) { + const auto methods = recovery_methods(ClosedFormModelRecoveryMethod{model}); + if constexpr (HasRecoveryAdmissibility) { + return prepare_variable_recovery(DeclaredModelRecoveryAdmissibility{model}, + methods); + } else { + return prepare_variable_recovery(FiniteModelRecoveryAdmissibility{}, methods); + } + } else { + return prepare_variable_recovery(FiniteModelRecoveryAdmissibility{}, + recovery_methods(IdentityModelRecoveryMethod{})); + } +} + +/// Adapter from the common ADC-750 prepared nonlinear provider to one explicit recovery method. +/// Recoverable numerical failures advance the declared chain; fatal evaluation failures reject the +/// attempt. Both decisions remain visible in the final RecoveryOutcome. +template +struct PreparedLocalNonlinearRecoveryMethod { + static constexpr RecoveryMethodKind kind = RecoveryMethodKind::kPreparedLocalNonlinear; + ProblemFactory problem_factory; + + POPS_HD RecoveryMethodResult operator()(const Real (&conserved)[N], + const Real (&initial_guess)[N]) const { + const auto problem = problem_factory(conserved); + const LocalNonlinearCellResult local = + solve_prepared_local_nonlinear(problem, initial_guess); + RecoveryMethodResult result; + for (int component = 0; component < N; ++component) + result.value[component] = local.value[component]; + result.iterations = local.iterations; + result.evaluations = local.evaluations; + result.residual_norm = local.residual_norm; + result.failing_component = local.failing_component; + result.reason_code = local.reason_code; + result.cause = recovery_cause_from_local_nonlinear_status(local.status); + + switch (local.status) { + case LocalNonlinearStatus::kConverged: + result.action = RecoveryMethodAction::kCandidate; + break; + case LocalNonlinearStatus::kInvalidEvaluation: + case LocalNonlinearStatus::kEvaluationReject: + case LocalNonlinearStatus::kEvaluationFailed: + result.action = RecoveryMethodAction::kReject; + break; + case LocalNonlinearStatus::kIterationLimit: + case LocalNonlinearStatus::kSingularJacobian: + case LocalNonlinearStatus::kInadmissibleCandidate: + case LocalNonlinearStatus::kSafeguardFailure: + case LocalNonlinearStatus::kUnsupportedCapability: + case LocalNonlinearStatus::kEvaluationRetry: + result.action = RecoveryMethodAction::kContinueChain; + break; + } + return result; + } +}; + +template +POPS_HD constexpr auto prepared_local_nonlinear_recovery(ProblemFactory problem_factory) { + return PreparedLocalNonlinearRecoveryMethod{problem_factory}; +} + +/// One caller-owned, trivially copyable warm-start slot. A topology/state-generation mismatch is +/// an explicit cache miss; reading a stale slot never mutates or silently refreshes it. +template +struct RecoveryWarmStartSlot { + Real value[N] = {}; + std::uint64_t topology_generation = 0; + std::uint64_t state_generation = 0; + bool valid = false; + + POPS_HD bool current(std::uint64_t expected_topology, std::uint64_t expected_state) const { + return valid && topology_generation == expected_topology && state_generation == expected_state; + } + + POPS_HD bool load_if_current(std::uint64_t expected_topology, std::uint64_t expected_state, + Real (&destination)[N]) const { + if (!current(expected_topology, expected_state)) + return false; + recovery_detail::copy_vector(value, destination); + return true; + } + + POPS_HD void store(const Real (&source)[N], std::uint64_t topology, std::uint64_t state) { + recovery_detail::copy_vector(source, value); + topology_generation = topology; + state_generation = state; + valid = true; + } + + POPS_HD void invalidate() { valid = false; } +}; + +enum class RecoveryPublicationState : int { + kOpen = 0, + kTentative = 1, + kCommitted = 2, + kRolledBack = 3, +}; + +/// Transaction for the only mutation point of accepted variables and their warm-start cache. +/// A failed/rejected outcome cannot enter the tentative state. Rollback restores both snapshots +/// exactly; commit makes the already-staged candidate durable to the caller. +template +class RecoveryPublicationTransaction { + public: + POPS_HD RecoveryPublicationTransaction(Real (&accepted_value)[N], RecoveryWarmStartSlot& cache) + : accepted_value_(&accepted_value), cache_(&cache), cache_snapshot_(cache) { + recovery_detail::copy_vector(accepted_value, value_snapshot_); + } + + RecoveryPublicationTransaction(const RecoveryPublicationTransaction&) = delete; + RecoveryPublicationTransaction& operator=(const RecoveryPublicationTransaction&) = delete; + RecoveryPublicationTransaction(RecoveryPublicationTransaction&&) = delete; + RecoveryPublicationTransaction& operator=(RecoveryPublicationTransaction&&) = delete; + + /// A tentative publication is never allowed to escape merely because a caller returns early. + /// Device code has no exception unwinding contract to lean on, so scope exit itself is the final + /// fail-closed guard. Only an explicit commit makes the staged value and cache durable. + POPS_HD ~RecoveryPublicationTransaction() { + if (state_ == RecoveryPublicationState::kOpen || state_ == RecoveryPublicationState::kTentative) + (void)rollback(); + } + + POPS_HD bool publish_tentative(const RecoveryOutcome& outcome, + std::uint64_t topology_generation, + std::uint64_t state_generation) { + if (state_ != RecoveryPublicationState::kOpen || !outcome.publication_permitted()) + return false; + recovery_detail::copy_vector(outcome.value, *accepted_value_); + cache_->store(outcome.value, topology_generation, state_generation); + state_ = RecoveryPublicationState::kTentative; + return true; + } + + POPS_HD bool commit() { + if (state_ != RecoveryPublicationState::kTentative) + return false; + state_ = RecoveryPublicationState::kCommitted; + return true; + } + + POPS_HD bool rollback() { + if (state_ == RecoveryPublicationState::kCommitted || + state_ == RecoveryPublicationState::kRolledBack) + return false; + recovery_detail::copy_vector(value_snapshot_, *accepted_value_); + *cache_ = cache_snapshot_; + state_ = RecoveryPublicationState::kRolledBack; + return true; + } + + POPS_HD RecoveryPublicationState state() const { return state_; } + + private: + Real (*accepted_value_)[N]; + RecoveryWarmStartSlot* cache_; + Real value_snapshot_[N] = {}; + RecoveryWarmStartSlot cache_snapshot_; + RecoveryPublicationState state_ = RecoveryPublicationState::kOpen; +}; + +static_assert(std::is_trivially_copyable_v>, + "warm-start slots must remain device-copyable PODs"); + +} // namespace pops diff --git a/include/pops/numerics/spatial/embedded_boundary/operator.hpp b/include/pops/numerics/spatial/embedded_boundary/operator.hpp index 951b7ac69..873211b8e 100644 --- a/include/pops/numerics/spatial/embedded_boundary/operator.hpp +++ b/include/pops/numerics/spatial/embedded_boundary/operator.hpp @@ -218,13 +218,18 @@ struct EbFaceFluxXKernel { fx(i, j, c) = Real(0); return; } - const auto L = - reconstruct_pp(model, u, i - 1, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rr = - reconstruct_pp(model, u, i, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); + const auto L = reconstruct_pp_recovered(model, u, i - 1, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rr = reconstruct_pp_recovered(model, u, i, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, L, Rr)) { + for (int c = 0; c < Model::n_vars; ++c) + fx(i, j, c) = Real(0); + return; + } const FaceContext face = FaceContext::axis_aligned(0, alpha); const auto evaluation = - evaluate_numerical_flux_at(nflux, model, L, ax, i - 1, j, Rr, ax, i, j, face); + evaluate_numerical_flux_at(nflux, model, L.value, ax, i - 1, j, Rr.value, ax, i, j, face); failures.record(evaluation, failure); if (!evaluation.succeeded()) { // This face field is transactional scratch. Keep later divergence arithmetic finite while @@ -264,13 +269,18 @@ struct EbFaceFluxYKernel { fy(i, j, c) = Real(0); return; } - const auto L = - reconstruct_pp(model, u, i, j - 1, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rr = - reconstruct_pp(model, u, i, j, 1, -1, lim, recon_prim, pos_floor, pos_comp); + const auto L = reconstruct_pp_recovered(model, u, i, j - 1, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rr = reconstruct_pp_recovered(model, u, i, j, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, L, Rr)) { + for (int c = 0; c < Model::n_vars; ++c) + fy(i, j, c) = Real(0); + return; + } const FaceContext face = FaceContext::axis_aligned(1, alpha); const auto evaluation = - evaluate_numerical_flux_at(nflux, model, L, ax, i, j - 1, Rr, ax, i, j, face); + evaluate_numerical_flux_at(nflux, model, L.value, ax, i, j - 1, Rr.value, ax, i, j, face); failures.record(evaluation, failure); if (!evaluation.succeeded()) { for (int c = 0; c < Model::n_vars; ++c) @@ -414,7 +424,7 @@ void assemble_rhs_eb_with_metrics(const Model& model, const MultiFab& U, const M /// above. No flux crosses an active/inactive face. This API accepts any device-callable level set and /// contains no shape-specific transport branch. /// -/// @tparam Limiter reconstruction (NoSlope / Minmod / VanLeer / Weno5), like the Cartesian operator. +/// @tparam Limiter prepared reconstruction policy, like the Cartesian operator. /// @tparam NumericalFlux flux policy (RusanovFlux by default). /// @param ls POPS_HD callable level set (e.g. detail::DiscDomain): ls < 0 inside. /// @param kappa_min volume fraction floor (small-cell clamp), default kEbKappaMin. diff --git a/include/pops/numerics/spatial/operators/cartesian_operator.hpp b/include/pops/numerics/spatial/operators/cartesian_operator.hpp index 04d6b09e3..037115c60 100644 --- a/include/pops/numerics/spatial/operators/cartesian_operator.hpp +++ b/include/pops/numerics/spatial/operators/cartesian_operator.hpp @@ -67,38 +67,44 @@ struct AssembleRhsKernel { const Aux Ac = load_aux()>(ax, i, j); // x faces: reconstruction of the states on either side of each face - const auto Lxm = - reconstruct_pp(model, u, i - 1, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rxm = - reconstruct_pp(model, u, i, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); - const auto Lxp = - reconstruct_pp(model, u, i, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rxp = - reconstruct_pp(model, u, i + 1, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); + const auto Lxm = reconstruct_pp_recovered(model, u, i - 1, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rxm = reconstruct_pp_recovered(model, u, i, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); + const auto Lxp = reconstruct_pp_recovered(model, u, i, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rxp = reconstruct_pp_recovered(model, u, i + 1, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); const FaceContext xface = FaceContext::axis_aligned(0); - const auto evaluation_xm = - evaluate_numerical_flux_at(nflux, model, Lxm, ax, i - 1, j, Rxm, ax, i, j, xface); - const auto evaluation_xp = - evaluate_numerical_flux_at(nflux, model, Lxp, ax, i, j, Rxp, ax, i + 1, j, xface); + const auto Lym = reconstruct_pp_recovered(model, u, i, j - 1, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rym = reconstruct_pp_recovered(model, u, i, j, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + const auto Lyp = reconstruct_pp_recovered(model, u, i, j, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Ryp = reconstruct_pp_recovered(model, u, i, j + 1, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, Lxm, Rxm, Lxp, Rxp, Lym, Rym, Lyp, + Ryp)) { + for (int c = 0; c < Model::n_vars; ++c) + r(i, j, c) = Real(0); + return; + } + const auto evaluation_xm = evaluate_numerical_flux_at(nflux, model, Lxm.value, ax, i - 1, j, + Rxm.value, ax, i, j, xface); + const auto evaluation_xp = evaluate_numerical_flux_at(nflux, model, Lxp.value, ax, i, j, + Rxp.value, ax, i + 1, j, xface); failures.record(evaluation_xm, failure); failures.record(evaluation_xp, failure); const auto Fxm = apply_face_measure(evaluation_xm.checked_density(), xface).value; const auto Fxp = apply_face_measure(evaluation_xp.checked_density(), xface).value; // y faces - const auto Lym = - reconstruct_pp(model, u, i, j - 1, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rym = - reconstruct_pp(model, u, i, j, 1, -1, lim, recon_prim, pos_floor, pos_comp); - const auto Lyp = - reconstruct_pp(model, u, i, j, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Ryp = - reconstruct_pp(model, u, i, j + 1, 1, -1, lim, recon_prim, pos_floor, pos_comp); const FaceContext yface = FaceContext::axis_aligned(1); - const auto evaluation_ym = - evaluate_numerical_flux_at(nflux, model, Lym, ax, i, j - 1, Rym, ax, i, j, yface); - const auto evaluation_yp = - evaluate_numerical_flux_at(nflux, model, Lyp, ax, i, j, Ryp, ax, i, j + 1, yface); + const auto evaluation_ym = evaluate_numerical_flux_at(nflux, model, Lym.value, ax, i, j - 1, + Rym.value, ax, i, j, yface); + const auto evaluation_yp = evaluate_numerical_flux_at(nflux, model, Lyp.value, ax, i, j, + Ryp.value, ax, i, j + 1, yface); failures.record(evaluation_ym, failure); failures.record(evaluation_yp, failure); const auto Fym = apply_face_measure(evaluation_ym.checked_density(), yface).value; @@ -170,17 +176,23 @@ struct HllFaceSpeedXKernel { bool recon_prim; Real pos_floor; int pos_comp; + FluxEvaluationRecorder failures; - POPS_HD void operator()(int i, int j) const { - const auto left = - reconstruct_pp(model, u, i - 1, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto right = - reconstruct_pp(model, u, i, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); + POPS_HD void operator()(int i, int j, std::uint64_t& failure) const { + const auto left = reconstruct_pp_recovered(model, u, i - 1, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto right = reconstruct_pp_recovered(model, u, i, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, left, right)) { + ws(i, j, 0) = Real(0); + ws(i, j, 1) = Real(0); + return; + } const FaceContext face = FaceContext::axis_aligned(0); const PhysicalFluxView physical{model}; Real lower, upper; - hll_speeds(physical, make_face_trace_at(left, ax, i - 1, j), - make_face_trace_at(right, ax, i, j), face, lower, upper); + hll_speeds(physical, make_face_trace_at(left.value, ax, i - 1, j), + make_face_trace_at(right.value, ax, i, j), face, lower, upper); ws(i, j, 0) = lower; ws(i, j, 1) = upper; } @@ -197,17 +209,23 @@ struct HllFaceSpeedYKernel { bool recon_prim; Real pos_floor; int pos_comp; + FluxEvaluationRecorder failures; - POPS_HD void operator()(int i, int j) const { - const auto left = - reconstruct_pp(model, u, i, j - 1, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto right = - reconstruct_pp(model, u, i, j, 1, -1, lim, recon_prim, pos_floor, pos_comp); + POPS_HD void operator()(int i, int j, std::uint64_t& failure) const { + const auto left = reconstruct_pp_recovered(model, u, i, j - 1, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto right = reconstruct_pp_recovered(model, u, i, j, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, left, right)) { + ws(i, j, 2) = Real(0); + ws(i, j, 3) = Real(0); + return; + } const FaceContext face = FaceContext::axis_aligned(1); const PhysicalFluxView physical{model}; Real lower, upper; - hll_speeds(physical, make_face_trace_at(left, ax, i, j - 1), - make_face_trace_at(right, ax, i, j), face, lower, upper); + hll_speeds(physical, make_face_trace_at(left.value, ax, i, j - 1), + make_face_trace_at(right.value, ax, i, j), face, lower, upper); ws(i, j, 2) = lower; ws(i, j, 3) = upper; } @@ -216,17 +234,20 @@ struct HllFaceSpeedYKernel { template inline void fill_hll_face_speed_cache(const Model& model, const MultiFab& U, const MultiFab& aux, MultiFab& cache, const Limiter& limiter, bool recon_prim, - Real pos_floor, int pos_comp) { + Real pos_floor, int pos_comp, + FluxEvaluationTracker& failures) { for (int local = 0; local < U.local_size(); ++local) { const ConstArray4 state = U.fab(local).const_array(); const ConstArray4 providers = aux.fab(local).const_array(); Array4 speeds = cache.fab(local).array(); - for_each_cell(xface_box(U.box(local)), - HllFaceSpeedXKernel{model, state, providers, speeds, limiter, - recon_prim, pos_floor, pos_comp}); - for_each_cell(yface_box(U.box(local)), - HllFaceSpeedYKernel{model, state, providers, speeds, limiter, - recon_prim, pos_floor, pos_comp}); + failures.merge(reduce_max_uint64_cell( + xface_box(U.box(local)), + HllFaceSpeedXKernel{model, state, providers, speeds, limiter, recon_prim, + pos_floor, pos_comp, failures.recorder()})); + failures.merge(reduce_max_uint64_cell( + yface_box(U.box(local)), + HllFaceSpeedYKernel{model, state, providers, speeds, limiter, recon_prim, + pos_floor, pos_comp, failures.recorder()})); } } @@ -248,47 +269,53 @@ struct AssembleRhsHllCachedKernel { const Aux Ac = load_aux()>(ax, i, j); // x faces: reconstruction of the states on both sides of each face - const auto Lxm = - reconstruct_pp(model, u, i - 1, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rxm = - reconstruct_pp(model, u, i, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); - const auto Lxp = - reconstruct_pp(model, u, i, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rxp = - reconstruct_pp(model, u, i + 1, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); + const auto Lxm = reconstruct_pp_recovered(model, u, i - 1, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rxm = reconstruct_pp_recovered(model, u, i, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); + const auto Lxp = reconstruct_pp_recovered(model, u, i, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rxp = reconstruct_pp_recovered(model, u, i + 1, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); const Real sLxm = ws(i, j, 0), sRxm = ws(i, j, 1); const Real sLxp = ws(i + 1, j, 0), sRxp = ws(i + 1, j, 1); const FaceContext xface = FaceContext::axis_aligned(0); const PhysicalFluxView physical{model}; + const auto Lym = reconstruct_pp_recovered(model, u, i, j - 1, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rym = reconstruct_pp_recovered(model, u, i, j, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + const auto Lyp = reconstruct_pp_recovered(model, u, i, j, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Ryp = reconstruct_pp_recovered(model, u, i, j + 1, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, Lxm, Rxm, Lxp, Rxp, Lym, Rym, Lyp, + Ryp)) { + for (int c = 0; c < Model::n_vars; ++c) + r(i, j, c) = Real(0); + return; + } const auto evaluation_xm = - hll_flux_with_speeds(physical, make_face_trace_at(Lxm, ax, i - 1, j), - make_face_trace_at(Rxm, ax, i, j), xface, sLxm, sRxm); + hll_flux_with_speeds(physical, make_face_trace_at(Lxm.value, ax, i - 1, j), + make_face_trace_at(Rxm.value, ax, i, j), xface, sLxm, sRxm); const auto evaluation_xp = - hll_flux_with_speeds(physical, make_face_trace_at(Lxp, ax, i, j), - make_face_trace_at(Rxp, ax, i + 1, j), xface, sLxp, sRxp); + hll_flux_with_speeds(physical, make_face_trace_at(Lxp.value, ax, i, j), + make_face_trace_at(Rxp.value, ax, i + 1, j), xface, sLxp, sRxp); failures.record(evaluation_xm, failure); failures.record(evaluation_xp, failure); const auto Fxm = apply_face_measure(evaluation_xm.checked_density(), xface).value; const auto Fxp = apply_face_measure(evaluation_xp.checked_density(), xface).value; // y faces (components 2/3 hold the exact interval of the indexed y-normal face) - const auto Lym = - reconstruct_pp(model, u, i, j - 1, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rym = - reconstruct_pp(model, u, i, j, 1, -1, lim, recon_prim, pos_floor, pos_comp); - const auto Lyp = - reconstruct_pp(model, u, i, j, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Ryp = - reconstruct_pp(model, u, i, j + 1, 1, -1, lim, recon_prim, pos_floor, pos_comp); const Real sLym = ws(i, j, 2), sRym = ws(i, j, 3); const Real sLyp = ws(i, j + 1, 2), sRyp = ws(i, j + 1, 3); const FaceContext yface = FaceContext::axis_aligned(1); const auto evaluation_ym = - hll_flux_with_speeds(physical, make_face_trace_at(Lym, ax, i, j - 1), - make_face_trace_at(Rym, ax, i, j), yface, sLym, sRym); + hll_flux_with_speeds(physical, make_face_trace_at(Lym.value, ax, i, j - 1), + make_face_trace_at(Rym.value, ax, i, j), yface, sLym, sRym); const auto evaluation_yp = - hll_flux_with_speeds(physical, make_face_trace_at(Lyp, ax, i, j), - make_face_trace_at(Ryp, ax, i, j + 1), yface, sLyp, sRyp); + hll_flux_with_speeds(physical, make_face_trace_at(Lyp.value, ax, i, j), + make_face_trace_at(Ryp.value, ax, i, j + 1), yface, sLyp, sRyp); failures.record(evaluation_ym, failure); failures.record(evaluation_yp, failure); const auto Fym = apply_face_measure(evaluation_ym.checked_density(), yface).value; @@ -326,16 +353,21 @@ struct FaceFluxHllCachedXKernel { FluxEvaluationRecorder failures; POPS_HD void operator()(int i, int j, std::uint64_t& failure) const { - const auto left = - reconstruct_pp(model, u, i - 1, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto right = - reconstruct_pp(model, u, i, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); + const auto left = reconstruct_pp_recovered(model, u, i - 1, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto right = reconstruct_pp_recovered(model, u, i, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, left, right)) { + for (int component = 0; component < Model::n_vars; ++component) + flux(i, j, component) = Real(0); + return; + } const Real speed_left = ws(i, j, 0), speed_right = ws(i, j, 1); const FaceContext face = FaceContext::axis_aligned(0); const PhysicalFluxView physical{model}; const auto evaluation = hll_flux_with_speeds( - physical, make_face_trace_at(left, ax, i - 1, j), - make_face_trace_at(right, ax, i, j), face, speed_left, speed_right); + physical, make_face_trace_at(left.value, ax, i - 1, j), + make_face_trace_at(right.value, ax, i, j), face, speed_left, speed_right); failures.record(evaluation, failure); const auto value = apply_face_measure(evaluation.checked_density(), face).value; for (int component = 0; component < Model::n_vars; ++component) @@ -364,16 +396,21 @@ struct FaceFluxHllCachedYKernel { FluxEvaluationRecorder failures; POPS_HD void operator()(int i, int j, std::uint64_t& failure) const { - const auto left = - reconstruct_pp(model, u, i, j - 1, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto right = - reconstruct_pp(model, u, i, j, 1, -1, lim, recon_prim, pos_floor, pos_comp); + const auto left = reconstruct_pp_recovered(model, u, i, j - 1, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto right = reconstruct_pp_recovered(model, u, i, j, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, left, right)) { + for (int component = 0; component < Model::n_vars; ++component) + flux(i, j, component) = Real(0); + return; + } const Real speed_left = ws(i, j, 2), speed_right = ws(i, j, 3); const FaceContext face = FaceContext::axis_aligned(1); const PhysicalFluxView physical{model}; const auto evaluation = hll_flux_with_speeds( - physical, make_face_trace_at(left, ax, i, j - 1), - make_face_trace_at(right, ax, i, j), face, speed_left, speed_right); + physical, make_face_trace_at(left.value, ax, i, j - 1), + make_face_trace_at(right.value, ax, i, j), face, speed_left, speed_right); failures.record(evaluation, failure); const auto value = apply_face_measure(evaluation.checked_density(), face).value; for (int component = 0; component < Model::n_vars; ++component) @@ -408,8 +445,9 @@ void assemble_rhs_hll_cached(const Model& model, const MultiFab& U, const MultiF const Real dx = geom.dx(), dy = geom.dy(); Limiter lim = configured_reconstruction(weno_eps); const int pos_comp = detail::positivity_comp(pos_floor); - detail::fill_hll_face_speed_cache(model, U, aux, cache, lim, recon_prim, pos_floor, pos_comp); FluxEvaluationTracker failures{process_world_flux_collective}; + detail::fill_hll_face_speed_cache(model, U, aux, cache, lim, recon_prim, pos_floor, pos_comp, + failures); for (int li = 0; li < U.local_size(); ++li) { const ConstArray4 u = U.fab(li).const_array(); const ConstArray4 ax = aux.fab(li).const_array(); @@ -436,8 +474,9 @@ void compute_face_fluxes_hll_cached(const Model& model, const MultiFab& U, const cache = MultiFab(U.box_array(), U.dmap(), 4, 1); Limiter limiter = configured_reconstruction(weno_eps); const int pos_comp = detail::positivity_comp(pos_floor); - detail::fill_hll_face_speed_cache(model, U, aux, cache, limiter, recon_prim, pos_floor, pos_comp); FluxEvaluationTracker failures{process_world_flux_collective}; + detail::fill_hll_face_speed_cache(model, U, aux, cache, limiter, recon_prim, pos_floor, pos_comp, + failures); for (int local = 0; local < U.local_size(); ++local) { const ConstArray4 state = U.fab(local).const_array(); const ConstArray4 providers = aux.fab(local).const_array(); diff --git a/include/pops/numerics/spatial/operators/masked_operator.hpp b/include/pops/numerics/spatial/operators/masked_operator.hpp index 9de6b8213..fba84607b 100644 --- a/include/pops/numerics/spatial/operators/masked_operator.hpp +++ b/include/pops/numerics/spatial/operators/masked_operator.hpp @@ -105,52 +105,68 @@ struct AssembleRhsMaskedKernel { const FaceContext xface = FaceContext::axis_aligned(0); typename Model::State Fxm{}, Fxp{}; if (!omission.omit(0, -1, i, j) && mask_active(mask, i - 1, j)) { - const auto Lxm = - reconstruct_pp(model, u, i - 1, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rxm = - reconstruct_pp(model, u, i, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); - const auto evaluation = - evaluate_numerical_flux_at(nflux, model, Lxm, ax, i - 1, j, Rxm, ax, i, j, xface); - failures.record(evaluation, failure); - evaluations_succeeded = evaluations_succeeded && evaluation.succeeded(); - Fxm = apply_face_measure(evaluation.checked_density(), xface).value; + const auto Lxm = reconstruct_pp_recovered(model, u, i - 1, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rxm = reconstruct_pp_recovered(model, u, i, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); + if (record_reconstruction_recoveries(failures, failure, Lxm, Rxm)) { + const auto evaluation = evaluate_numerical_flux_at(nflux, model, Lxm.value, ax, i - 1, j, + Rxm.value, ax, i, j, xface); + failures.record(evaluation, failure); + evaluations_succeeded = evaluations_succeeded && evaluation.succeeded(); + Fxm = apply_face_measure(evaluation.checked_density(), xface).value; + } else { + evaluations_succeeded = false; + } } if (!omission.omit(0, +1, i, j) && mask_active(mask, i + 1, j)) { - const auto Lxp = - reconstruct_pp(model, u, i, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rxp = - reconstruct_pp(model, u, i + 1, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); - const auto evaluation = - evaluate_numerical_flux_at(nflux, model, Lxp, ax, i, j, Rxp, ax, i + 1, j, xface); - failures.record(evaluation, failure); - evaluations_succeeded = evaluations_succeeded && evaluation.succeeded(); - Fxp = apply_face_measure(evaluation.checked_density(), xface).value; + const auto Lxp = reconstruct_pp_recovered(model, u, i, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rxp = reconstruct_pp_recovered(model, u, i + 1, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); + if (record_reconstruction_recoveries(failures, failure, Lxp, Rxp)) { + const auto evaluation = evaluate_numerical_flux_at(nflux, model, Lxp.value, ax, i, j, + Rxp.value, ax, i + 1, j, xface); + failures.record(evaluation, failure); + evaluations_succeeded = evaluations_succeeded && evaluation.succeeded(); + Fxp = apply_face_measure(evaluation.checked_density(), xface).value; + } else { + evaluations_succeeded = false; + } } // y faces const FaceContext yface = FaceContext::axis_aligned(1); typename Model::State Fym{}, Fyp{}; if (!omission.omit(1, -1, i, j) && mask_active(mask, i, j - 1)) { - const auto Lym = - reconstruct_pp(model, u, i, j - 1, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rym = - reconstruct_pp(model, u, i, j, 1, -1, lim, recon_prim, pos_floor, pos_comp); - const auto evaluation = - evaluate_numerical_flux_at(nflux, model, Lym, ax, i, j - 1, Rym, ax, i, j, yface); - failures.record(evaluation, failure); - evaluations_succeeded = evaluations_succeeded && evaluation.succeeded(); - Fym = apply_face_measure(evaluation.checked_density(), yface).value; + const auto Lym = reconstruct_pp_recovered(model, u, i, j - 1, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rym = reconstruct_pp_recovered(model, u, i, j, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + if (record_reconstruction_recoveries(failures, failure, Lym, Rym)) { + const auto evaluation = evaluate_numerical_flux_at(nflux, model, Lym.value, ax, i, j - 1, + Rym.value, ax, i, j, yface); + failures.record(evaluation, failure); + evaluations_succeeded = evaluations_succeeded && evaluation.succeeded(); + Fym = apply_face_measure(evaluation.checked_density(), yface).value; + } else { + evaluations_succeeded = false; + } } if (!omission.omit(1, +1, i, j) && mask_active(mask, i, j + 1)) { - const auto Lyp = - reconstruct_pp(model, u, i, j, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Ryp = - reconstruct_pp(model, u, i, j + 1, 1, -1, lim, recon_prim, pos_floor, pos_comp); - const auto evaluation = - evaluate_numerical_flux_at(nflux, model, Lyp, ax, i, j, Ryp, ax, i, j + 1, yface); - failures.record(evaluation, failure); - evaluations_succeeded = evaluations_succeeded && evaluation.succeeded(); - Fyp = apply_face_measure(evaluation.checked_density(), yface).value; + const auto Lyp = reconstruct_pp_recovered(model, u, i, j, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Ryp = reconstruct_pp_recovered(model, u, i, j + 1, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + if (record_reconstruction_recoveries(failures, failure, Lyp, Ryp)) { + const auto evaluation = evaluate_numerical_flux_at(nflux, model, Lyp.value, ax, i, j, + Ryp.value, ax, i, j + 1, yface); + failures.record(evaluation, failure); + evaluations_succeeded = evaluations_succeeded && evaluation.succeeded(); + Fyp = apply_face_measure(evaluation.checked_density(), yface).value; + } else { + evaluations_succeeded = false; + } } const auto S = model.source(load_state(u, i, j), Ac); diff --git a/include/pops/numerics/spatial/operators/polar_operator.hpp b/include/pops/numerics/spatial/operators/polar_operator.hpp index 9d60f9192..85ca4523b 100644 --- a/include/pops/numerics/spatial/operators/polar_operator.hpp +++ b/include/pops/numerics/spatial/operators/polar_operator.hpp @@ -141,13 +141,18 @@ struct PolarFaceFluxRKernel { // Reconstructed states on either side of the radial face i (REUSES Cartesian reconstruct_pp<>, // dir == 0). L = extrapolation from cell i-1 toward its + face; R = from cell i toward its // - face. - const auto L = - reconstruct_pp(model, u, i - 1, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rr = - reconstruct_pp(model, u, i, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); + const auto L = reconstruct_pp_recovered(model, u, i - 1, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rr = reconstruct_pp_recovered(model, u, i, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, L, Rr)) { + for (int c = 0; c < Model::n_vars; ++c) + fr(i, j, c) = Real(0); + return; + } const FaceContext face = FaceContext::axis_aligned(0, rf); const auto evaluation = - evaluate_numerical_flux_at(nflux, model, L, ax, i - 1, j, Rr, ax, i, j, face); + evaluate_numerical_flux_at(nflux, model, L.value, ax, i - 1, j, Rr.value, ax, i, j, face); failures.record(evaluation, failure); if (!evaluation.succeeded()) { for (int c = 0; c < Model::n_vars; ++c) @@ -180,13 +185,18 @@ struct PolarFaceFluxThetaKernel { int pos_comp = 0; ///< component of the Density role (resolved by the host caller) FluxEvaluationRecorder failures; POPS_HD void operator()(int i, int j, std::uint64_t& failure) const { - const auto L = - reconstruct_pp(model, u, i, j - 1, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rr = - reconstruct_pp(model, u, i, j, 1, -1, lim, recon_prim, pos_floor, pos_comp); + const auto L = reconstruct_pp_recovered(model, u, i, j - 1, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rr = reconstruct_pp_recovered(model, u, i, j, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, L, Rr)) { + for (int c = 0; c < Model::n_vars; ++c) + ft(i, j, c) = Real(0); + return; + } const FaceContext face = FaceContext::axis_aligned(1); const auto evaluation = - evaluate_numerical_flux_at(nflux, model, L, ax, i, j - 1, Rr, ax, i, j, face); + evaluate_numerical_flux_at(nflux, model, L.value, ax, i, j - 1, Rr.value, ax, i, j, face); failures.record(evaluation, failure); if (!evaluation.succeeded()) { for (int c = 0; c < Model::n_vars; ++c) diff --git a/include/pops/numerics/spatial/primitives/face_flux.hpp b/include/pops/numerics/spatial/primitives/face_flux.hpp index 60ebb652a..087677534 100644 --- a/include/pops/numerics/spatial/primitives/face_flux.hpp +++ b/include/pops/numerics/spatial/primitives/face_flux.hpp @@ -22,9 +22,11 @@ #include #include #include +#include #include #include +#include #include // require_reconstruction_ghosts: state without the stencil width -> clear error namespace pops { @@ -75,6 +77,77 @@ struct CachedPrimitiveComponentSampler { } // namespace detail +/// Typed result of one face-state reconstruction. +/// +/// `value` is consumable only when `recovery.publication_permitted()` is true. On a recovery +/// refusal it contains the conservative source-cell average solely so a device kernel can keep its +/// scratch finite while the report travels through the transport reduction. Production kernels +/// must consume the report before evaluating a numerical flux. +template +struct ReconstructedFaceState { + typename Model::State value{}; + RecoveryReport recovery{}; + + POPS_HD bool publication_permitted() const { return recovery.publication_permitted(); } +}; + +template +struct RecoveredFacePrimitive { + typename Model::Prim value{}; + RecoveryReport recovery{}; +}; + +template +POPS_HD inline bool record_reconstruction_recoveries(const FluxEvaluationRecorder& failures, + std::uint64_t& failure, + const Reconstructed&... reconstructed) { + (failures.record_recovery(reconstructed.recovery, failure), ...); + return (reconstructed.publication_permitted() && ...); +} + +template +POPS_HD inline ReconstructedFaceState recovered_face_state( + const typename Model::State& value) { + RecoveryReport report; + report.status = RecoveryStatus::kRecovered; + report.cause = RecoveryCause::kNone; + return {value, report}; +} + +template +POPS_HD inline typename Model::State value_only_face_state( + const ReconstructedFaceState& reconstructed) { + if (reconstructed.publication_permitted()) + return reconstructed.value; + typename Model::State invalid{}; + for (int component = 0; component < Model::n_vars; ++component) + invalid[component] = std::numeric_limits::quiet_NaN(); + return invalid; +} + +template +POPS_HD inline auto recover_face_primitive(const Model& model, + const typename Model::State& conservative) { + constexpr int N = Model::n_vars; + Real conserved[N] = {}; + Real initial_guess[N] = {}; + for (int component = 0; component < N; ++component) + conserved[component] = initial_guess[component] = conservative[component]; + + // This compatibility plan is a fixed-size aggregate. Construction and execution are both + // device-inline, allocation-free and callback-free. Model-specific prepared chains can replace + // this plan without changing the reconstruction protocol. + const auto plan = prepare_model_variable_recovery(model); + const RecoveryOutcome outcome = recover_prepared_variable(plan, conserved, initial_guess); + + RecoveredFacePrimitive result; + result.recovery = recovery_report(outcome); + if (outcome.publication_permitted()) + for (int component = 0; component < N; ++component) + result.value[component] = outcome.value[component]; + return result; +} + /// reconstruct: face value at (i,j) extrapolated in direction dir. /// /// sgn = +1 -> +dir face of (i,j); sgn = -1 -> -dir face. Reconstructs in PRIMITIVE @@ -84,9 +157,10 @@ struct CachedPrimitiveComponentSampler { /// n_ghost is used only to validate the storage envelope. /// INVARIANT: POINTWISE function, does NOT loop over the grid. POPS_HD. template -POPS_HD inline typename Model::State reconstruct(const Model& model, const ConstArray4& u, int i, - int j, int dir, Real sgn, const Limiter& lim, - bool prim) { +POPS_HD inline ReconstructedFaceState reconstruct_recovered(const Model& model, + const ConstArray4& u, int i, + int j, int dir, Real sgn, + const Limiter& lim, bool prim) { static_assert( ReconstructionPolicy, "a reconstruction policy must declare positive formal_order/n_ghost metadata and implement " @@ -98,13 +172,21 @@ POPS_HD inline typename Model::State reconstruct(const Model& model, const Const using Prim = typename Model::Prim; Prim Pf{}; if constexpr (SlopeReconstruction) { - const Prim P0 = model.to_primitive(load_state(u, i, j)); - const Prim Pm = - model.to_primitive(load_state(u, dir == 0 ? i - 1 : i, dir == 0 ? j : j - 1)); - const Prim Pp = - model.to_primitive(load_state(u, dir == 0 ? i + 1 : i, dir == 0 ? j : j + 1)); + const auto P0 = recover_face_primitive(model, load_state(u, i, j)); + if (!P0.recovery.publication_permitted()) + return {load_state(u, i, j), P0.recovery}; + const auto Pm = recover_face_primitive( + model, load_state(u, dir == 0 ? i - 1 : i, dir == 0 ? j : j - 1)); + if (!Pm.recovery.publication_permitted()) + return {load_state(u, i, j), Pm.recovery}; + const auto Pp = recover_face_primitive( + model, load_state(u, dir == 0 ? i + 1 : i, dir == 0 ? j : j + 1)); + if (!Pp.recovery.publication_permitted()) + return {load_state(u, i, j), Pp.recovery}; for (int c = 0; c < Model::n_vars; ++c) - Pf[c] = P0[c] + sgn * Real(0.5) * lim.limited_slope(P0[c] - Pm[c], Pp[c] - P0[c]); + Pf[c] = P0.value[c] + + sgn * Real(0.5) * + lim.limited_slope(P0.value[c] - Pm.value[c], Pp.value[c] - P0.value[c]); } else if constexpr (StencilReconstruction) { const int orientation = (sgn > Real(0)) ? 1 : -1; detail::PrimitiveStencilCache cache{}; @@ -113,14 +195,17 @@ POPS_HD inline typename Model::State reconstruct(const Model& model, const Const const int displacement = orientation * offset; const auto state = load_state(u, dir == 0 ? i + displacement : i, dir == 0 ? j : j + displacement); - cache.at(offset) = model.to_primitive(state); + const auto primitive = recover_face_primitive(model, state); + if (!primitive.recovery.publication_permitted()) + return {load_state(u, i, j), primitive.recovery}; + cache.at(offset) = primitive.value; } for (int c = 0; c < Model::n_vars; ++c) { const detail::CachedPrimitiveComponentSampler sample{&cache, c}; Pf[c] = lim.stencil_face_value(sample); } } - return model.to_conservative(Pf); + return recovered_face_state(model.to_conservative(Pf)); } } (void)model; @@ -145,20 +230,41 @@ POPS_HD inline typename Model::State reconstruct(const Model& model, const Const s[c] = lim.stencil_face_value(sample); } } - return s; + return recovered_face_state(s); +} + +/// Compatibility value-only entry point. Production spatial kernels use +/// reconstruct_recovered() and consume its RecoveryReport before any flux evaluation. This +/// wrapper preserves the low-level API for callers that only need conservative reconstruction and +/// returns an explicit non-finite sentinel if a primitive recovery is refused; it never exposes the +/// finite transactional scratch as a valid candidate. +template +POPS_HD inline typename Model::State reconstruct(const Model& model, const ConstArray4& u, int i, + int j, int dir, Real sgn, const Limiter& lim, + bool prim) { + return value_only_face_state(reconstruct_recovered(model, u, i, j, dir, sgn, lim, prim)); } /// reconstruct_pp: reconstruct + zhang_shu_scale positivity limiter on the returned state. /// /// (i, j) is the SOURCE cell of the reconstruction: it is to ITS average that the face state is /// brought back. pos_floor <= 0 -> strictly identical to reconstruct (short-circuit). POPS_HD. +template +POPS_HD inline ReconstructedFaceState reconstruct_pp_recovered( + const Model& model, const ConstArray4& u, int i, int j, int dir, Real sgn, const Limiter& lim, + bool prim, Real pos_floor, int pos_comp) { + auto reconstructed = reconstruct_recovered(model, u, i, j, dir, sgn, lim, prim); + if (reconstructed.publication_permitted()) + zhang_shu_scale(reconstructed.value, u, i, j, pos_floor, pos_comp); + return reconstructed; +} + template POPS_HD inline typename Model::State reconstruct_pp(const Model& model, const ConstArray4& u, int i, int j, int dir, Real sgn, const Limiter& lim, bool prim, Real pos_floor, int pos_comp) { - typename Model::State s = reconstruct(model, u, i, j, dir, sgn, lim, prim); - zhang_shu_scale(s, u, i, j, pos_floor, pos_comp); - return s; + return value_only_face_state( + reconstruct_pp_recovered(model, u, i, j, dir, sgn, lim, prim, pos_floor, pos_comp)); } namespace detail { @@ -218,13 +324,20 @@ struct FaceFluxXKernel { int pos_comp = 0; ///< component of the Density role (resolved by the host caller) FluxEvaluationRecorder failures; POPS_HD void operator()(int i, int j, std::uint64_t& failure) const { - const auto L = - reconstruct_pp(model, u, i - 1, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rr = - reconstruct_pp(model, u, i, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); + const auto L = reconstruct_pp_recovered(model, u, i - 1, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rr = reconstruct_pp_recovered(model, u, i, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); + failures.record_recovery(L.recovery, failure); + failures.record_recovery(Rr.recovery, failure); + if (!L.publication_permitted() || !Rr.publication_permitted()) { + for (int c = 0; c < Model::n_vars; ++c) + fx(i, j, c) = Real(0); + return; + } const FaceContext face = FaceContext::axis_aligned(0); const auto evaluation = - evaluate_numerical_flux_at(nflux, model, L, ax, i - 1, j, Rr, ax, i, j, face); + evaluate_numerical_flux_at(nflux, model, L.value, ax, i - 1, j, Rr.value, ax, i, j, face); failures.record(evaluation, failure); const auto F = apply_face_measure(evaluation.checked_density(), face).value; for (int c = 0; c < Model::n_vars; ++c) @@ -255,13 +368,20 @@ struct FaceFluxYKernel { int pos_comp = 0; ///< component of the Density role (resolved by the host caller) FluxEvaluationRecorder failures; POPS_HD void operator()(int i, int j, std::uint64_t& failure) const { - const auto L = - reconstruct_pp(model, u, i, j - 1, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rr = - reconstruct_pp(model, u, i, j, 1, -1, lim, recon_prim, pos_floor, pos_comp); + const auto L = reconstruct_pp_recovered(model, u, i, j - 1, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rr = reconstruct_pp_recovered(model, u, i, j, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + failures.record_recovery(L.recovery, failure); + failures.record_recovery(Rr.recovery, failure); + if (!L.publication_permitted() || !Rr.publication_permitted()) { + for (int c = 0; c < Model::n_vars; ++c) + fy(i, j, c) = Real(0); + return; + } const FaceContext face = FaceContext::axis_aligned(1); const auto evaluation = - evaluate_numerical_flux_at(nflux, model, L, ax, i, j - 1, Rr, ax, i, j, face); + evaluate_numerical_flux_at(nflux, model, L.value, ax, i, j - 1, Rr.value, ax, i, j, face); failures.record(evaluation, failure); const auto F = apply_face_measure(evaluation.checked_density(), face).value; for (int c = 0; c < Model::n_vars; ++c) diff --git a/include/pops/numerics/spatial_operator.hpp b/include/pops/numerics/spatial_operator.hpp index f695ad02a..7bc898868 100644 --- a/include/pops/numerics/spatial_operator.hpp +++ b/include/pops/numerics/spatial_operator.hpp @@ -12,7 +12,7 @@ /// Modules (one-way dependency DAG, bottom to top): /// - spatial/state_access.hpp DiffusiveModel, SourceFreeModel, load_state, load_aux. /// - spatial/positivity.hpp zhang_shu_scale, detail::positivity_comp. -/// - spatial/face_flux.hpp reconstruct, reconstruct_pp, require_reconstruction_ghosts, +/// - spatial/face_flux.hpp typed/fallible face reconstruction, positivity, /// xface_box / yface_box, compute_face_fluxes. /// - spatial/wave_speed.hpp max_wave_speed_mf and step-bound reductions, the hotspot /// diagnostic. diff --git a/include/pops/physics/composition/composite.hpp b/include/pops/physics/composition/composite.hpp index aacb38bd6..28481c731 100644 --- a/include/pops/physics/composition/composite.hpp +++ b/include/pops/physics/composition/composite.hpp @@ -63,6 +63,17 @@ struct CompositeModel { static VariableSet conservative_vars() { return Hyperbolic::conservative_vars(); } static VariableSet primitive_vars() { return Hyperbolic::primitive_vars(); } + /// Optional primitive-recovery admissibility, forwarded from the hyperbolic brick. Keeping the + /// method concept-gated preserves the historical finite-only recovery path for every brick that + /// does not declare a physical policy. + POPS_HD bool recovery_admissible(const Prim& p, int* failing_component) const + requires requires(const Hyperbolic h, const Prim q, int* component) { + { h.recovery_admissible(q, component) } -> std::same_as; + } + { + return hyp.recovery_admissible(p, failing_component); + } + POPS_HD Real pressure(const State& u) const requires requires(const Hyperbolic h, const State s) { h.pressure(s); } { diff --git a/include/pops/runtime/amr_system.hpp b/include/pops/runtime/amr_system.hpp index 71c70314a..8b080a1ac 100644 --- a/include/pops/runtime/amr_system.hpp +++ b/include/pops/runtime/amr_system.hpp @@ -269,8 +269,9 @@ class AmrSystem { /// @param name block name: INDEXES the block (set_density(name), mass(name), density(name)). In /// multi-block the name must be unique; mono-block an empty name targets the single block. /// @param model composition of bricks (transport/source/elliptic + parameters) - /// @param limiter "none" | "minmod" | "vanleer" | "weno5" (weno5 = WENO5-Z, 3 ghosts; - /// native low-level stencil route). The resolved Case route derives its + /// @param limiter "none" | "minmod" | "vanleer" | "weno5" | "mc" | "superbee" + /// (weno5 = WENO5-Z, 3 ghosts; native low-level stencil route). The resolved + /// Case route derives its /// coarse/fine order and halo requirements from this spatial descriptor and /// selects the minimum sufficient conservative provider. /// @param riemann "rusanov" | "hll" (generic signed-wave, requires model.wave_speeds) | "hllc" @@ -350,17 +351,27 @@ class AmrSystem { POPS_EXPORT void install_boundary_plan(const std::string& name, const std::string& identity, int required_depth, const std::vector& face_types, - const std::vector& face_values, int ncomp, + const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces = {}, const std::string& state_identity = {}, PreparedBoundaryReadDependencies read_dependencies = {}); - /// Exact-topology overload; preserves the translation-only exported ABI above. + /// Exact-topology overload. Periodic endpoint maps remain topology metadata beside the sole + /// model-aware physical-law plan; they never restore component-wise BCRec transport semantics. POPS_EXPORT void install_boundary_plan( const std::string& name, const std::string& identity, int required_depth, - const std::vector& face_types, const std::vector& face_values, int ncomp, + const std::vector& face_types, const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces, const std::string& state_identity, PreparedBoundaryReadDependencies read_dependencies, - std::vector periodic_identifications); + std::vector periodic_identifications, + const std::vector& face_representations = {}, + const std::vector& face_converter_identities = {}, + const std::vector>& face_analytic_opcodes = {}, + const std::vector>& face_analytic_literals = {}, + const std::vector& face_analytic_clocks = {}); /// Register the exact state Handle independently from physical-boundary ownership. POPS_EXPORT void install_block_state_route(const std::string& name, const std::string& state_identity); @@ -373,6 +384,9 @@ class AmrSystem { POPS_EXPORT void install_ghost_boundary_component( const std::string& name, PreparedBoundaryComponentSpec spec, std::shared_ptr component); + POPS_EXPORT void install_boundary_flux_component( + const std::string& name, PreparedBoundaryComponentSpec spec, + std::shared_ptr component); POPS_EXPORT void install_field_boundary_residual_component( const std::string& name, PreparedBoundaryComponentSpec spec, std::shared_ptr component); @@ -852,6 +866,9 @@ class AmrSystem { /// Human/audit-readable qualification rows decoded from the same accepted image persisted as bytes. POPS_EXPORT std::vector> program_accepted_state_manifest() const; POPS_EXPORT std::vector> program_clock_manifest() const; + /// Accepted temporal-partition provider, synchronization tick and per-rung cell counts. The rows + /// are decoded from the same opaque image used by strict restart, never a capability ledger. + POPS_EXPORT std::vector> program_temporal_partition_manifest() const; POPS_EXPORT std::vector> program_flux_ledger_manifest() const; POPS_EXPORT std::vector> program_interface_flux_ledger_manifest() const; POPS_EXPORT std::vector> program_sync_manifest() const; diff --git a/include/pops/runtime/analytic/collective_preflight.hpp b/include/pops/runtime/analytic/collective_preflight.hpp index 3d0464cce..3f288d51a 100644 --- a/include/pops/runtime/analytic/collective_preflight.hpp +++ b/include/pops/runtime/analytic/collective_preflight.hpp @@ -117,30 +117,29 @@ inline std::string canonical_analytic_request(std::string_view operation, } // namespace detail -/// Run a non-mutating local validator/preparer on every rank, convert any rank-local exception into -/// one collective failure, then require exact equality of the complete canonical request. The -/// returned object may own prepared native programs or staged registry nodes; callers publish it -/// only after this function returns. -/// -/// This is a control-plane collective. Every rank in @p communicator must call it in the same order. -/// With one rank the original validation exception is rethrown, preserving the serial API contract. -template -[[nodiscard]] auto collectively_prepare_analytic_request( - std::string_view operation, std::span text_metadata, - std::span real_metadata, const AnalyticOpcodeRows& opcodes, - const AnalyticLiteralRows& literals, LocalPrepare&& local_prepare, +/// Run one fallible local preparation and one fallible exact canonicalization under the same +/// collective failure boundary. This is the common transaction used by analytic requests whose +/// metadata is richer than the standard text/real/opcode tables. +template +[[nodiscard]] auto collectively_prepare_exact_analytic_request( + std::string_view operation, LocalPrepare&& local_prepare, + LocalCanonicalize&& local_canonicalize, const CommunicatorView& communicator = world_communicator_view()) -> std::invoke_result_t { using Result = std::invoke_result_t; static_assert(!std::is_void_v); + static_assert(std::is_convertible_v, std::string>); std::optional prepared; std::string canonical_payload; std::exception_ptr local_failure; try { prepared.emplace(std::invoke(std::forward(local_prepare))); - canonical_payload = detail::canonical_analytic_request(operation, text_metadata, real_metadata, - opcodes, literals); + const std::string request = + std::string(std::invoke(std::forward(local_canonicalize))); + detail::append_analytic_bytes(canonical_payload, "pops.analytic.exact-request.v1"); + detail::append_analytic_bytes(canonical_payload, operation); + detail::append_analytic_bytes(canonical_payload, request); } catch (...) { local_failure = std::current_exception(); } @@ -161,6 +160,29 @@ template return std::move(*prepared); } +/// Run a non-mutating local validator/preparer on every rank, convert any rank-local exception into +/// one collective failure, then require exact equality of the complete canonical request. The +/// returned object may own prepared native programs or staged registry nodes; callers publish it +/// only after this function returns. +/// +/// This is a control-plane collective. Every rank in @p communicator must call it in the same order. +/// With one rank the original validation exception is rethrown, preserving the serial API contract. +template +[[nodiscard]] auto collectively_prepare_analytic_request( + std::string_view operation, std::span text_metadata, + std::span real_metadata, const AnalyticOpcodeRows& opcodes, + const AnalyticLiteralRows& literals, LocalPrepare&& local_prepare, + const CommunicatorView& communicator = world_communicator_view()) + -> std::invoke_result_t { + return collectively_prepare_exact_analytic_request( + operation, std::forward(local_prepare), + [&]() { + return detail::canonical_analytic_request(operation, text_metadata, real_metadata, opcodes, + literals); + }, + communicator); +} + template [[nodiscard]] auto collectively_prepare_analytic_request( std::string_view operation, std::initializer_list text_metadata, diff --git a/include/pops/runtime/builders/block/block_builder.hpp b/include/pops/runtime/builders/block/block_builder.hpp index 6aeb0af88..f63dd7510 100644 --- a/include/pops/runtime/builders/block/block_builder.hpp +++ b/include/pops/runtime/builders/block/block_builder.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include // assemble_rhs_eb (cut-cell EB) + detail::DiscLevelSet (T5-PR2) #include @@ -125,7 +126,9 @@ template inline void assemble_rhs_without_prepared_interfaces( const Model& model, MultiFab& state, const GridContext& context, MultiFab& residual, bool reconstruct_primitive, Real positivity_floor, Real weno_epsilon = kWenoEpsilon, - const std::shared_ptr& ws_cache = {}) { + const std::shared_ptr& ws_cache = {}, + const runtime::multiblock::BoundaryEvaluationPoint* point = nullptr, + const PreparedGridBoundarySession* boundary = nullptr) { std::vector xboxes; std::vector yboxes; xboxes.reserve(static_cast(state.box_array().size())); @@ -151,6 +154,15 @@ inline void assemble_rhs_without_prepared_interfaces( context.geom.dy(), reconstruct_primitive, positivity_floor, weno_epsilon); } + if (context.boundary_plan && context.boundary_plan->has_flux_transformations()) { + if (point == nullptr) + throw std::logic_error( + "post-Riemann boundary flux transformation requires a BoundaryEvaluationPoint"); + if (boundary != nullptr) + transform_grid_boundary_fluxes(state, fx, fy, *boundary, *point); + else + transform_grid_boundary_fluxes(state, fx, fy, context, *point); + } zero_prepared_interface_fluxes(fx, fy, context); mf_eval_rhs(model, state, *context.aux, fx, fy, context.geom.dx(), context.geom.dy(), residual); } @@ -175,19 +187,14 @@ struct BlockRhsEval { Real weno_eps = kWenoEpsilon; ///< ADC-645: WENO-Z regulariser (default = historical, bit-identical) void operator()(MultiFab& U, MultiFab& R) const { + if (ctx->boundary_plan && ctx->boundary_plan->has_flux_transformations()) + throw std::logic_error( + "post-Riemann boundary flux transformation requires a BoundaryEvaluationPoint"); + if (ctx->boundary_plan && ctx->boundary_plan->has_omitted_faces()) + throw std::logic_error( + "prepared shared-interface flux requires BoundaryEvaluationPoint group authority"); fill_grid_ghosts(U, *ctx); - if constexpr (std::is_same_v) { - if (ws_cache) { - // Re-allocate the scratch at the current layout (4 components, 1 ghost): covers an AMR regrid - // or a first call (shared_ptr to an empty MultiFab). Otherwise reuse the existing allocation. - if (!detail::wave_speed_cache_matches(*ws_cache, U)) - *ws_cache = MultiFab(U.box_array(), U.dmap(), 4, 1); - assemble_rhs_hll_cached(model, U, *ctx->aux, ctx->geom, R, *ws_cache, recon_prim, - pos_floor, weno_eps); - return; - } - } - assemble_rhs(model, U, *ctx->aux, ctx->geom, R, recon_prim, pos_floor, weno_eps); + eval_core_filled(U, R); } void operator()(const runtime::multiblock::BoundaryEvaluationPoint& point, MultiFab& U, @@ -202,20 +209,23 @@ struct BlockRhsEval { void eval_core(const runtime::multiblock::BoundaryEvaluationPoint& point, MultiFab& U, MultiFab& R) const { fill_grid_ghosts(U, *ctx, point); - eval_core_filled(U, R); + eval_core_filled(U, R, &point, nullptr); } void eval_core(const runtime::multiblock::BoundaryEvaluationPoint& point, MultiFab& U, MultiFab& R, const PreparedGridBoundarySession& boundary) const { fill_grid_ghosts(U, boundary, point); - eval_core_filled(U, R); + eval_core_filled(U, R, &point, &boundary); } private: - void eval_core_filled(MultiFab& U, MultiFab& R) const { - if (ctx->boundary_plan && ctx->boundary_plan->has_omitted_faces()) { - assemble_rhs_without_prepared_interfaces(model, U, *ctx, R, recon_prim, - pos_floor, weno_eps, ws_cache); + void eval_core_filled(MultiFab& U, MultiFab& R, + const runtime::multiblock::BoundaryEvaluationPoint* point = nullptr, + const PreparedGridBoundarySession* boundary = nullptr) const { + if (ctx->boundary_plan && (ctx->boundary_plan->has_omitted_faces() || + ctx->boundary_plan->has_flux_transformations())) { + assemble_rhs_without_prepared_interfaces( + model, U, *ctx, R, recon_prim, pos_floor, weno_eps, ws_cache, point, boundary); return; } if constexpr (std::is_same_v) { @@ -788,6 +798,9 @@ inline int block_n_ghost(const std::string& lim) { static_assert(limiter_n_ghost_ct("vanleer") == VanLeer::n_ghost, "kLimiters[vanleer].n_ghost drifted"); static_assert(limiter_n_ghost_ct("weno5") == Weno5::n_ghost, "kLimiters[weno5].n_ghost drifted"); + static_assert(limiter_n_ghost_ct("mc") == MC::n_ghost, "kLimiters[mc].n_ghost drifted"); + static_assert(limiter_n_ghost_ct("superbee") == Superbee::n_ghost, + "kLimiters[superbee].n_ghost drifted"); return limiter_n_ghost(lim); } @@ -896,17 +909,29 @@ std::function make_poisson_rhs(const Model& m) /// PER-CELL (one cell) cons <-> prim conversions of the MODEL, type-erased over arrays of /// Model::n_vars doubles. First = primitive -> conservative (M.to_conservative, init from the -/// primitives), second = conservative -> primitive (M.to_primitive, diagnostic). Captures the model by -/// value (frozen when the block is added). For a model WITHOUT a conversion (pure scalar, no -/// hyperbolic brick) both are the IDENTITY -- exact for a scalar transport (prim == cons). -/// Model::Prim shares the Model::n_vars width of State (HyperbolicPhysicalModel contract), so the flat -/// arrays align component by component. Shared by add_block (native) and add_compiled_model (compiled): +/// primitives), second = conservative -> primitive through one PreparedVariableRecovery method. +/// The second closure returns a RecoveryReport and writes its output only after recovery succeeds. +/// Captures the model by value (frozen when the block is added). For a model WITHOUT a conversion +/// (pure scalar, no hyperbolic brick) both formulas are the IDENTITY -- exact for scalar transport +/// (prim == cons) -- while the recovery route still rejects non-finite publication. +/// This flat ABI requires Model::Prim to share the Model::n_vars width of State; make_cell_convert +/// enforces that additional constraint at compile time because HyperbolicPhysicalModel itself only +/// types the forward/inverse maps. Shared by add_block (native) and add_compiled_model (compiled): /// the SAME conversion serves both paths. template -std::pair, std::function> +std::pair, + std::function> make_cell_convert(const Model& m) { constexpr int NV = Model::n_vars; + const auto recovery_plan = prepare_model_variable_recovery(m); if constexpr (HasPrimitiveVars) { + static_assert( + requires { std::integral_constant{}; }, + "make_cell_convert requires a compile-time primitive-state width"); + if constexpr (requires { std::integral_constant{}; }) + static_assert( + Model::Prim::size() == NV, + "make_cell_convert requires primitive and conservative states to have equal arity"); auto p2c = [m](const double* in, double* out) { typename Model::Prim p{}; for (int c = 0; c < NV; ++c) @@ -915,23 +940,43 @@ make_cell_convert(const Model& m) { for (int c = 0; c < NV; ++c) out[c] = static_cast(u[c]); }; - auto c2p = [m](const double* in, double* out) { - typename Model::State u{}; - for (int c = 0; c < NV; ++c) - u[c] = static_cast(in[c]); - const typename Model::Prim p = m.to_primitive(u); - for (int c = 0; c < NV; ++c) - out[c] = static_cast(p[c]); + auto c2p = [recovery_plan](const double* in, double* out) { + constexpr int N = Model::n_vars; + Real conserved[N] = {}; + Real initial_guess[N] = {}; + for (int c = 0; c < N; ++c) + conserved[c] = initial_guess[c] = static_cast(in[c]); + const RecoveryOutcome outcome = + recover_prepared_variable(recovery_plan, conserved, initial_guess); + if (!outcome.publication_permitted()) + return recovery_report(outcome); + for (int c = 0; c < N; ++c) + out[c] = static_cast(outcome.value[c]); + return recovery_report(outcome); }; return {std::function(p2c), - std::function(c2p)}; + std::function(c2p)}; } else { - auto id = [](const double* in, double* out) { + auto p2c = [](const double* in, double* out) { for (int c = 0; c < NV; ++c) out[c] = in[c]; }; - return {std::function(id), - std::function(id)}; + auto c2p = [recovery_plan](const double* in, double* out) { + constexpr int N = Model::n_vars; + Real conserved[N] = {}; + Real initial_guess[N] = {}; + for (int c = 0; c < N; ++c) + conserved[c] = initial_guess[c] = static_cast(in[c]); + const RecoveryOutcome outcome = + recover_prepared_variable(recovery_plan, conserved, initial_guess); + if (!outcome.publication_permitted()) + return recovery_report(outcome); + for (int c = 0; c < N; ++c) + out[c] = static_cast(outcome.value[c]); + return recovery_report(outcome); + }; + return {std::function(p2c), + std::function(c2p)}; } } diff --git a/include/pops/runtime/builders/block/block_seam.hpp b/include/pops/runtime/builders/block/block_seam.hpp index 68edc60f5..ac46cd171 100644 --- a/include/pops/runtime/builders/block/block_seam.hpp +++ b/include/pops/runtime/builders/block/block_seam.hpp @@ -38,8 +38,9 @@ struct BuiltBlock { BlockClosures clo; std::function max_speed; std::function add_poisson_rhs; - std::function src_freq, stab_dt; // optional step bounds (model traits) - std::function prim_to_cons, cons_to_prim; // System::CellConvert + std::function src_freq, stab_dt; // optional step bounds (model traits) + std::function prim_to_cons; // System::CellConvert + std::function cons_to_prim; // System::CellRecovery int aux_width = 0; // aux_comps() (Cartesian); unused on the polar path (no ensure_aux_width) }; diff --git a/include/pops/runtime/builders/compiled/amr_dsl_block.hpp b/include/pops/runtime/builders/compiled/amr_dsl_block.hpp index ed83da32f..83e718405 100644 --- a/include/pops/runtime/builders/compiled/amr_dsl_block.hpp +++ b/include/pops/runtime/builders/compiled/amr_dsl_block.hpp @@ -207,12 +207,17 @@ AmrRuntimeBlock build_amr_block(const Model& model, const SharedAmrLayout& S, const int nc = Model::n_vars; const int ng = Limiter::n_ghost; const int nlev = S.nlev(); - std::shared_ptr boundary_plan; + std::shared_ptr prepared_boundary_plan; if (S.boundary_plans != nullptr) { auto found = S.boundary_plans->find(name); if (found != S.boundary_plans->end()) - boundary_plan = found->second; + prepared_boundary_plan = found->second; } + if (prepared_boundary_plan && prepared_boundary_plan->requires_fixed_state_conversion()) { + auto conversion = make_cell_convert(model); + prepared_boundary_plan->prepare_fixed_state_conversion(conversion.first); + } + std::shared_ptr boundary_plan = prepared_boundary_plan; BCRec transport_bc; if (!S.base_per.x) transport_bc.xlo = transport_bc.xhi = BCType::Foextrap; @@ -500,6 +505,7 @@ AmrRuntimeBlock build_amr_block(const Model& model, const SharedAmrLayout& S, boundary.fill_same_level_and_physical(U, point); detail::compute_amr_face_fluxes(model, U, aux, Fx, Fy, geom.dx(), geom.dy(), rprim, pf, weps, ws_cache); + transform_grid_boundary_fluxes(U, Fx, Fy, boundary, point); detail::zero_prepared_interface_fluxes(Fx, Fy, boundary.context()); pops::mf_eval_rhs(model, U, aux, Fx, Fy, geom.dx(), geom.dy(), R); }; @@ -512,6 +518,7 @@ AmrRuntimeBlock build_amr_block(const Model& model, const SharedAmrLayout& S, boundary.fill_same_level_and_physical(U, point); detail::compute_amr_face_fluxes(sm, U, aux, Fx, Fy, geom.dx(), geom.dy(), rprim, pf, weps, ws_cache); + transform_grid_boundary_fluxes(U, Fx, Fy, boundary, point); detail::zero_prepared_interface_fluxes(Fx, Fy, boundary.context()); pops::mf_eval_rhs(sm, U, aux, Fx, Fy, geom.dx(), geom.dy(), R); }; diff --git a/include/pops/runtime/builders/compiled/dsl_block.hpp b/include/pops/runtime/builders/compiled/dsl_block.hpp index 7214fdfa1..5782b90e3 100644 --- a/include/pops/runtime/builders/compiled/dsl_block.hpp +++ b/include/pops/runtime/builders/compiled/dsl_block.hpp @@ -97,7 +97,8 @@ void add_compiled_model(System& sys, const std::string& name, Model model, // Scheme GHOSTS: WENO5 reads a 5-point stencil (3 ghosts) > the 2 allocated by install_block. // We reallocate the block state with block_n_ghost(limiter) -- SAME mechanism as add_block (PR #88) -- // so that fill_boundary + assemble_rhs do not read out of bounds on the System's real MultiFab. - // none/minmod/vanleer (<= 2 ghosts): no-op, allocation and result bit-identical to before. + // Any catalogue limiter requiring <= 2 ghosts (none/MUSCL family): no-op; allocation and result + // stay bit-identical to the prepared route. sys.set_block_ghosts(name, block_n_ghost(limiter)); } diff --git a/include/pops/runtime/builders/scheme_dispatch.hpp b/include/pops/runtime/builders/scheme_dispatch.hpp index 78b791853..d75961baa 100644 --- a/include/pops/runtime/builders/scheme_dispatch.hpp +++ b/include/pops/runtime/builders/scheme_dispatch.hpp @@ -1,7 +1,7 @@ #pragma once #include // POPS_COLD_FN -#include // NoSlope / Minmod / VanLeer / Weno5 +#include // Prepared reconstruction policies #include // throw_registry_dispatch_mismatch #include // LimiterRouteId, route_token, kLimiterRoutes @@ -40,7 +40,9 @@ namespace pops { X(kNone, NoSlope) \ X(kMinmod, Minmod) \ X(kVanLeer, VanLeer) \ - X(kWeno5, Weno5) + X(kWeno5, Weno5) \ + X(kMc, MC) \ + X(kSuperbee, Superbee) namespace detail { constexpr int kLimiterXMacroCount = 0 diff --git a/include/pops/runtime/config/generated_component_abi.hpp b/include/pops/runtime/config/generated_component_abi.hpp index a698145a9..f1e1304bc 100644 --- a/include/pops/runtime/config/generated_component_abi.hpp +++ b/include/pops/runtime/config/generated_component_abi.hpp @@ -15,7 +15,7 @@ extern "C" { #endif #define POPS_COMPONENT_API_SYMBOL_V1 "pops_component_interface_v1" -#define POPS_COMPONENT_CATALOG_SHA256_V1 "5c67c081cf1808138583ed00856e6601c12384ae28e9c0f8cc7b8ce004c3b0f6" +#define POPS_COMPONENT_CATALOG_SHA256_V1 "a10653b4730d0e5a8d8b1c21d3bb4263f3ca8fc93ebdfb1af59b88c7cbce07f0" #define POPS_COMPONENT_PROTOCOL_ABI_V1 1u #define POPS_COMPONENT_COMMON_ABI_V1 1u @@ -26,6 +26,7 @@ typedef enum PopsNativeInterfaceIdV1 { POPS_NATIVE_INTERFACE_TAGGER_V2 = 3, POPS_NATIVE_INTERFACE_CLUSTERING_V1 = 4, POPS_NATIVE_INTERFACE_TRANSFER_V1 = 5, + POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1 = 6, POPS_NATIVE_INTERFACE_FIELD_SOLVER_V2 = 7, POPS_NATIVE_INTERFACE_WRITER_V1 = 8, POPS_NATIVE_INTERFACE_FIELD_TOPOLOGY_V2 = 9, @@ -289,6 +290,35 @@ typedef struct PopsGhostBoundaryApiV1 { PopsApplyRegionBatchFnV1 apply_region_batch; } PopsGhostBoundaryApiV1; +typedef struct PopsBoundaryFluxRequestV1 { + uint32_t struct_size; + const char* provider_identity; + const char* state_identity; + PopsConstFieldViewV1 base_outward_normal_flux; + PopsConstFieldViewV1 coordinates; + PopsConstFieldViewV1 outward_normals; + const double* face_measures; + PopsBoundaryRegionV1 region; + size_t dependency_count; + const PopsQualifiedConstFieldV1* dependencies; + size_t parameter_count; + const PopsQualifiedScalarV1* parameters; + PopsLogicalTimeV1 logical_time; + PopsExecutionContextV1 execution; +} PopsBoundaryFluxRequestV1; +typedef struct PopsBoundaryFluxResultV1 { + uint32_t struct_size; + PopsFieldViewV1 outward_normal_flux; + PopsComponentActionV1* actions; + PopsComponentStatusV1 status; +} PopsBoundaryFluxResultV1; +typedef int32_t (*PopsTransformBoundaryFacesFnV1)( + void*, const PopsBoundaryFluxRequestV1*, PopsBoundaryFluxResultV1*); +typedef struct PopsBoundaryFluxApiV1 { + PopsComponentTableHeaderV1 header; + PopsTransformBoundaryFacesFnV1 transform_faces; +} PopsBoundaryFluxApiV1; + typedef struct PopsFieldBoundaryRequestV1 { uint32_t struct_size; const char* closure_identity; @@ -724,6 +754,7 @@ inline constexpr size_t generated_native_interface_table_size( case POPS_NATIVE_INTERFACE_TAGGER_V2: return sizeof(PopsTaggerApiV2); case POPS_NATIVE_INTERFACE_CLUSTERING_V1: return sizeof(PopsClusteringApiV1); case POPS_NATIVE_INTERFACE_TRANSFER_V1: return sizeof(PopsTransferApiV1); + case POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1: return sizeof(PopsBoundaryFluxApiV1); case POPS_NATIVE_INTERFACE_FIELD_SOLVER_V2: return sizeof(PopsFieldSolverApiV2); case POPS_NATIVE_INTERFACE_WRITER_V1: return sizeof(PopsWriterApiV1); case POPS_NATIVE_INTERFACE_FIELD_TOPOLOGY_V2: return sizeof(PopsFieldTopologyApiV2); @@ -739,6 +770,7 @@ inline constexpr const char* generated_native_interface_table_name( case POPS_NATIVE_INTERFACE_TAGGER_V2: return "PopsTaggerApiV2"; case POPS_NATIVE_INTERFACE_CLUSTERING_V1: return "PopsClusteringApiV1"; case POPS_NATIVE_INTERFACE_TRANSFER_V1: return "PopsTransferApiV1"; + case POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1: return "PopsBoundaryFluxApiV1"; case POPS_NATIVE_INTERFACE_FIELD_SOLVER_V2: return "PopsFieldSolverApiV2"; case POPS_NATIVE_INTERFACE_WRITER_V1: return "PopsWriterApiV1"; case POPS_NATIVE_INTERFACE_FIELD_TOPOLOGY_V2: return "PopsFieldTopologyApiV2"; diff --git a/include/pops/runtime/config/generated_component_catalog.hpp b/include/pops/runtime/config/generated_component_catalog.hpp index 47aad9625..a409e94be 100644 --- a/include/pops/runtime/config/generated_component_catalog.hpp +++ b/include/pops/runtime/config/generated_component_catalog.hpp @@ -102,14 +102,18 @@ enum class LimiterRouteId : int { kMinmod = 1, kVanLeer = 2, kWeno5 = 3, + kMc = 4, + kSuperbee = 5, }; inline constexpr RouteInfo kLimiterRoutes[] = { {0, "none", "pops::NoSlope", "", ""}, {1, "minmod", "pops::Minmod", "", ""}, {2, "vanleer", "pops::VanLeer", "", ""}, {3, "weno5", "pops::Weno5", "3-cell halo", ""}, + {4, "mc", "pops::MC", "", ""}, + {5, "superbee", "pops::Superbee", "", ""}, }; -inline constexpr const char* kLimiterRouteTokensCsv = "none|minmod|vanleer|weno5"; +inline constexpr const char* kLimiterRouteTokensCsv = "none|minmod|vanleer|weno5|mc|superbee"; enum class ReconRouteId : int { kConservative = 0, @@ -241,6 +245,8 @@ inline constexpr LimiterTag kLimiters[] = { {"minmod", 2}, {"vanleer", 2}, {"weno5", 3}, + {"mc", 2}, + {"superbee", 2}, }; struct RiemannTag { @@ -301,9 +307,9 @@ inline constexpr int kComponentCatalogSchemaVersion = 1; inline constexpr int kComponentManifestSchemaVersion = 2; inline constexpr int kRouteRegistryVersion = 2; inline constexpr int kCapabilityVocabularyVersion = 4; -inline constexpr const char* kComponentCatalogSha256 = "5c67c081cf1808138583ed00856e6601c12384ae28e9c0f8cc7b8ce004c3b0f6"; -inline constexpr const char* kComponentCatalogSemanticSha256 = "adbb3693dc17eff5aa7b78415df35f011dfd2c64fc26eb9a98200923e52c47ea"; -inline constexpr const char* kRouteRegistrySignature = "v2:adbb3693dc17eff5aa7b78415df35f011dfd2c64fc26eb9a98200923e52c47ea"; +inline constexpr const char* kComponentCatalogSha256 = "a10653b4730d0e5a8d8b1c21d3bb4263f3ca8fc93ebdfb1af59b88c7cbce07f0"; +inline constexpr const char* kComponentCatalogSemanticSha256 = "9d7d38624833a7a7d7462ac7113bbdc215e6bce145ad1150b8cc1decf2d297b1"; +inline constexpr const char* kRouteRegistrySignature = "v2:9d7d38624833a7a7d7462ac7113bbdc215e6bce145ad1150b8cc1decf2d297b1"; inline constexpr const char* kComponentManifestSemanticFields[] = { "schema_version", "uri", diff --git a/include/pops/runtime/config/generated_route_accessors.inc b/include/pops/runtime/config/generated_route_accessors.inc index 78ad94f1e..2d90110f9 100644 --- a/include/pops/runtime/config/generated_route_accessors.inc +++ b/include/pops/runtime/config/generated_route_accessors.inc @@ -1,4 +1,4 @@ -// Generated from component catalog 5c67c081cf1808138583ed00856e6601c12384ae28e9c0f8cc7b8ce004c3b0f6; DO NOT EDIT. +// Generated from component catalog a10653b4730d0e5a8d8b1c21d3bb4263f3ca8fc93ebdfb1af59b88c7cbce07f0; DO NOT EDIT. // POPS_DEFINE_ROUTE_ACCESSORS must be defined by the including behavior header. POPS_DEFINE_ROUTE_ACCESSORS(riemann, RiemannRouteId, kRiemannRoutes, kRiemann); POPS_DEFINE_ROUTE_ACCESSORS(limiter, LimiterRouteId, kLimiterRoutes, kLimiter); diff --git a/include/pops/runtime/context/grid_context.hpp b/include/pops/runtime/context/grid_context.hpp index 5d3b985d8..bf6f1a1c0 100644 --- a/include/pops/runtime/context/grid_context.hpp +++ b/include/pops/runtime/context/grid_context.hpp @@ -185,6 +185,7 @@ class PreparedGridBoundarySession final { if (!context_.boundary_plan->has_component_boundaries()) return; plan_session_->prepare_ghost_executor(prototype, registry_, context_.geom); + plan_session_->prepare_flux_executor(prototype, registry_, context_.geom); if (!residual_outputs_.empty()) { bind_registry_(preparation_point, prototype, nullptr, &prototype); plan_session_->prepare_residual_executor(registry_, context_.geom); @@ -251,7 +252,7 @@ class PreparedGridBoundarySession final { return; } if (!context_.boundary_plan->has_component_boundaries()) { - plan_session_->fill_same_level_and_physical(state, context_.geom); + plan_session_->fill_same_level_and_physical(state, context_.geom, point); return; } bind_registry_(point, state, nullptr, nullptr); @@ -266,6 +267,14 @@ class PreparedGridBoundarySession final { plan_session_->add_residual(point, registry_, context_.geom); } + void transform_fluxes(MultiFab& state, MultiFab& fx, MultiFab& fy, + const runtime::multiblock::BoundaryEvaluationPoint& point) const { + if (!plan_session_ || !context_.boundary_plan->has_flux_transformations()) + return; + bind_registry_(point, state, nullptr, nullptr); + plan_session_->transform_fluxes(point, state, registry_, context_.geom, fx, fy); + } + void apply_jvp(MultiFab& state, const MultiFab& direction, MultiFab& output, const runtime::multiblock::BoundaryEvaluationPoint& point) const { if (!plan_session_ || !context_.boundary_plan->has_component_boundaries()) @@ -432,6 +441,21 @@ inline void fill_grid_ghosts(MultiFab& state, const PreparedGridBoundarySession& session.fill(state, point); } +inline void transform_grid_boundary_fluxes( + MultiFab& state, MultiFab& fx, MultiFab& fy, const GridContext& context, + const runtime::multiblock::BoundaryEvaluationPoint& point) { + if (context.boundary_plan && context.boundary_plan->has_flux_transformations()) + context.boundary_plan->transform_fluxes_control( + point, state, context.aux, context.geom, fx, fy, + ExecutionLane::world(context.boundary_plan->identity(), "::boundary-flux-control")); +} + +inline void transform_grid_boundary_fluxes( + MultiFab& state, MultiFab& fx, MultiFab& fy, const PreparedGridBoundarySession& session, + const runtime::multiblock::BoundaryEvaluationPoint& point) { + session.transform_fluxes(state, fx, fy, point); +} + inline void add_grid_boundary_residual(MultiFab& state, MultiFab& residual, const GridContext& context, const runtime::multiblock::BoundaryEvaluationPoint& point) { diff --git a/include/pops/runtime/dynamic/component_consumers.hpp b/include/pops/runtime/dynamic/component_consumers.hpp index ccdaeb506..d47114356 100644 --- a/include/pops/runtime/dynamic/component_consumers.hpp +++ b/include/pops/runtime/dynamic/component_consumers.hpp @@ -360,6 +360,76 @@ inline int apply_ghost_boundary(const PopsGhostBoundaryApiV1& api, void* state, return api.apply_region_batch(state, &request, &status); } +inline int transform_boundary_flux(const PopsBoundaryFluxApiV1& api, void* state, + const PopsBoundaryFluxRequestV1& request, + PopsBoundaryFluxResultV1& result) { + require_operation(api.transform_faces != nullptr, "transform_faces"); + validate_execution_context(request.execution); + validate_logical_time(request.logical_time); + validate_boundary_region(request.region); + if (request.struct_size < sizeof(PopsBoundaryFluxRequestV1) || + result.struct_size < sizeof(PopsBoundaryFluxResultV1) || + !component_text(request.provider_identity) || !component_text(request.state_identity) || + request.face_measures == nullptr || result.actions == nullptr || + request.region.kind != POPS_BOUNDARY_FACE_V1 || request.region.codimension != 1 || + request.region.axis_count != 1) + throw std::invalid_argument("boundary flux transformation request is incomplete"); + validate_execution_field(request.execution, request.base_outward_normal_flux, + "boundary base outward flux"); + validate_execution_field(request.execution, request.coordinates, "boundary flux coordinates"); + validate_execution_field(request.execution, request.outward_normals, "boundary outward normals"); + validate_execution_field(request.execution, result.outward_normal_flux, + "boundary transformed outward flux"); + if (!same_field_domain(request.base_outward_normal_flux, result.outward_normal_flux) || + !same_spatial_domain(request.base_outward_normal_flux, request.coordinates) || + !same_spatial_domain(request.base_outward_normal_flux, request.outward_normals) || + request.coordinates.component_count != static_cast(request.region.dimension) || + request.outward_normals.component_count != static_cast(request.region.dimension)) + throw std::invalid_argument("boundary flux field descriptors disagree"); + const std::uint32_t normal_axis = 1u << static_cast(request.region.axes[0]); + if (request.base_outward_normal_flux.centering != POPS_FIELD_CENTERING_FACE_V1 || + request.base_outward_normal_flux.centering_axes != normal_axis) + throw std::invalid_argument( + "boundary base outward flux is not centered on its authenticated face axis"); + const std::size_t point_count = field_point_count(request.base_outward_normal_flux); + const auto* coordinates = static_cast(request.coordinates.data); + const auto* normals = static_cast(request.outward_normals.data); + for (std::size_t point = 0; point < point_count; ++point) + if (!std::isfinite(request.face_measures[point]) || request.face_measures[point] <= 0.0) + throw std::invalid_argument("boundary flux face measure is not positive and finite"); + for (std::size_t j = 0; j < request.coordinates.extents[1]; ++j) + for (std::size_t i = 0; i < request.coordinates.extents[0]; ++i) + for (std::size_t component = 0; component < request.coordinates.component_count; + ++component) { + const auto coordinate_offset = + static_cast(i) * request.coordinates.axis_strides[0] + + static_cast(j) * request.coordinates.axis_strides[1] + + static_cast(component) * request.coordinates.component_stride; + const auto normal_offset = + static_cast(i) * request.outward_normals.axis_strides[0] + + static_cast(j) * request.outward_normals.axis_strides[1] + + static_cast(component) * request.outward_normals.component_stride; + const double expected = component == static_cast(request.region.axes[0]) + ? static_cast(request.region.sides[0]) + : 0.0; + if (!std::isfinite(coordinates[coordinate_offset]) || + !std::isfinite(normals[normal_offset]) || normals[normal_offset] != expected) + throw std::invalid_argument( + "boundary flux coordinates or outward normal disagree with the oriented face"); + } + validate_const_fields(request.dependencies, request.dependency_count, + "boundary flux dependencies"); + for (std::size_t index = 0; index < request.dependency_count; ++index) { + validate_execution_field(request.execution, request.dependencies[index].values, + "boundary flux dependency"); + if (!same_spatial_domain(request.base_outward_normal_flux, request.dependencies[index].values)) + throw std::invalid_argument( + "boundary flux dependency does not cover the transformed face points"); + } + validate_scalars(request.parameters, request.parameter_count, "boundary flux parameters"); + return api.transform_faces(state, &request, &result); +} + inline int evaluate_field_boundary(const PopsFieldBoundaryClosureApiV1& api, void* state, const PopsFieldBoundaryRequestV1& request, PopsComponentStatusV1& status, bool jvp) { diff --git a/include/pops/runtime/module_capabilities.hpp b/include/pops/runtime/module_capabilities.hpp index 3715dda77..555baddba 100644 --- a/include/pops/runtime/module_capabilities.hpp +++ b/include/pops/runtime/module_capabilities.hpp @@ -236,21 +236,19 @@ inline std::vector native_capability_routes( capability_route("reconstruction:firstorder", "available", "ghost_depth=1", kLayoutRouteTokensCsv, "production", "host", mpi, gpu), capability_route("reconstruction:muscl", "available", - "ghost_depth=2; native limiters minmod/vanleer", kLayoutRouteTokensCsv, - "production", "host", mpi, gpu), + "ghost_depth=2; native limiters minmod/vanleer/mc/superbee", + kLayoutRouteTokensCsv, "production", "host", mpi, gpu), capability_route( "reconstruction:weno5", "available", "ghost_depth=3; uniform and ratio-2 2D AMR routes are native; AMR selects the " "conservative order-5 coarse/fine provider for cell averages from resolved capabilities", kLayoutRouteTokensCsv, "production", "host", mpi, gpu), - capability_route("limiter:mc", "unavailable", - "catalogued but no native C++ limiter symbol exists", kLayoutRouteTokensCsv, - "none", "host", mpi, gpu, "limiter=MC()", "Minmod() or VanLeer()", - "use pops.numerics.reconstruction.limiters.Minmod()"), - capability_route("limiter:superbee", "unavailable", - "catalogued but no native C++ limiter symbol exists", kLayoutRouteTokensCsv, - "none", "host", mpi, gpu, "limiter=Superbee()", "Minmod() or VanLeer()", - "use pops.numerics.reconstruction.limiters.VanLeer()"), + capability_route("limiter:mc", "available", + "native POPS_HD MC slope policy; formal_order=2; ghost_depth=2", + kLayoutRouteTokensCsv, "production", "host", mpi, gpu), + capability_route("limiter:superbee", "available", + "native POPS_HD Superbee slope policy; formal_order=2; ghost_depth=2", + kLayoutRouteTokensCsv, "production", "host", mpi, gpu), capability_route("elliptic:geometric_mg", "available", "native multigrid route; supports variable epsilon", kLayoutRouteTokensCsv, "production", "host", mpi, gpu), diff --git a/include/pops/runtime/program/amr_program_checkpoint.hpp b/include/pops/runtime/program/amr_program_checkpoint.hpp index c0f5ebbc1..608400295 100644 --- a/include/pops/runtime/program/amr_program_checkpoint.hpp +++ b/include/pops/runtime/program/amr_program_checkpoint.hpp @@ -14,6 +14,7 @@ #include #include #include +#include namespace pops::runtime::program { @@ -52,6 +53,7 @@ struct AmrProgramSyncEvent { struct AmrProgramAcceptedState { std::vector level_clocks; std::map logical_clock_ticks; + CellTemporalPartitionAcceptedState temporal_partition; /// Rank-independent canonical image of the runtime-owned AMR tagging hysteresis. std::vector tagging_hysteresis_state; std::map history_owners; @@ -448,12 +450,25 @@ Map read_map(Reader& in, ReadValue&& read_value) { inline std::vector serialize_amr_program_accepted_state( const AmrProgramAcceptedState& state) { using namespace checkpoint_detail; + validate_cell_temporal_partition_state(state.temporal_partition); Writer out; - out.u64(0x3454534153504f50ULL); // "POPSAST4", little-endian bytes + out.u64(0x3554534153504f50ULL); // "POPSAST5", little-endian bytes out.size(state.level_clocks.size()); for (const auto& clock : state.level_clocks) write_clock(out, clock); write_map(out, state.logical_clock_ticks, [](Writer& w, std::int64_t value) { w.i64(value); }); + out.u64(static_cast(state.temporal_partition.kind)); + out.string(state.temporal_partition.provider_identity); + out.u64(state.temporal_partition.topology_epoch); + out.i64(state.temporal_partition.synchronization_tick); + out.i64(state.temporal_partition.tick_denominator); + out.size(state.temporal_partition.cells.size()); + for (const CellTemporalPartitionRecord& cell : state.temporal_partition.cells) { + out.i32(cell.level); + out.u64(cell.cell); + out.i32(cell.rung); + out.i64(cell.accepted_tick); + } out.bytes(state.tagging_hysteresis_state); write_map(out, state.history_owners, [](Writer& w, int v) { w.i32(v); }); write_map(out, state.history_states, [](Writer& w, const std::string& v) { w.string(v); }); @@ -520,7 +535,9 @@ inline AmrProgramAcceptedState deserialize_amr_program_accepted_state( const std::vector& bytes) { using namespace checkpoint_detail; Reader in(bytes); - if (in.u64() != 0x3454534153504f50ULL) + const std::uint64_t magic = in.u64(); + const bool carries_temporal_partition = magic == 0x3554534153504f50ULL; + if (!carries_temporal_partition && magic != 0x3454534153504f50ULL) throw std::runtime_error( "invalid AMR Program accepted-state payload: unsupported magic/version"); AmrProgramAcceptedState state; @@ -529,6 +546,30 @@ inline AmrProgramAcceptedState deserialize_amr_program_accepted_state( clock = read_clock(in); state.logical_clock_ticks = read_map(in, [](Reader& r) { return r.i64(); }); + if (carries_temporal_partition) { + const std::uint64_t kind = in.u64(); + if (kind > static_cast(TemporalPartitionKind::CellLocal)) + throw std::runtime_error( + "invalid AMR Program accepted-state payload: unsupported temporal partition kind"); + state.temporal_partition.kind = static_cast(kind); + state.temporal_partition.provider_identity = in.string(); + state.temporal_partition.topology_epoch = in.u64(); + state.temporal_partition.synchronization_tick = in.i64(); + state.temporal_partition.tick_denominator = in.i64(); + state.temporal_partition.cells.resize(in.size()); + for (CellTemporalPartitionRecord& cell : state.temporal_partition.cells) { + cell.level = in.i32(); + cell.cell = in.u64(); + cell.rung = in.i32(); + cell.accepted_tick = in.i64(); + } + try { + validate_cell_temporal_partition_state(state.temporal_partition); + } catch (const std::exception& error) { + throw std::runtime_error(std::string("invalid AMR Program accepted-state payload: ") + + error.what()); + } + } state.tagging_hysteresis_state = in.bytes(); state.history_owners = read_map>(in, [](Reader& r) { return r.i32(); }); diff --git a/include/pops/runtime/program/amr_program_context.hpp b/include/pops/runtime/program/amr_program_context.hpp index e350f675e..13003aca6 100644 --- a/include/pops/runtime/program/amr_program_context.hpp +++ b/include/pops/runtime/program/amr_program_context.hpp @@ -1066,6 +1066,10 @@ class AmrProgramContext : public ProgramExecutionServices { AttemptSnapshot& saved = attempt_snapshot_; capture_engine_attempt_snapshot_(saved, borrows_facade_snapshot); import_program_accepted_state_(); + // ADC-756 foundation: an authenticated cell-local checkpoint must never fall through to the + // existing hierarchy-global driver. The future prepared Kokkos partition executor will consume + // this same authority; until then, fail before the Program body or any published clock mutates. + temporal_partition_.require_global_execution_route(); capture_program_attempt_snapshot_(saved); conservative_ledger_.begin(); try { @@ -1170,6 +1174,16 @@ class AmrProgramContext : public ProgramExecutionServices { } void validate_program_accepted_state_(const AmrProgramAcceptedState& state) const { + validate_cell_temporal_partition_state(state.temporal_partition); + if (state.temporal_partition.kind == TemporalPartitionKind::CellLocal) { + if (state.temporal_partition.topology_epoch != eng_->topology_epoch()) + throw std::runtime_error( + "AMR Program cell-local temporal partition targets another topology epoch"); + for (const CellTemporalPartitionRecord& cell : state.temporal_partition.cells) + if (cell.level >= nlev()) + throw std::runtime_error( + "AMR Program cell-local temporal partition targets an inactive level"); + } if (state.level_clocks.size() != static_cast(nlev())) throw std::runtime_error( "AMR Program accepted state does not match the restored hierarchy level count"); @@ -1295,6 +1309,7 @@ class AmrProgramContext : public ProgramExecutionServices { const std::int64_t accepted_step = level_clocks_.empty() ? macro_step() : level_clocks_.front().macro_step; state.logical_clock_ticks = clock_schedule_.accepted_ticks(accepted_step); + state.temporal_partition = temporal_partition_.checkpoint(); state.tagging_hysteresis_state = eng_->checkpoint_tagging_state(); state.history_owners = history_owners_; state.history_states = history_state_ids_; @@ -1349,6 +1364,7 @@ class AmrProgramContext : public ProgramExecutionServices { const std::int64_t accepted_step = level_clocks_.empty() ? macro_step() : level_clocks_.front().macro_step; clock_schedule_.restore_accepted_ticks(state.logical_clock_ticks, accepted_step); + temporal_partition_.restore(std::move(state.temporal_partition)); history_owners_ = std::move(state.history_owners); history_state_ids_ = std::move(state.history_states); history_space_ids_ = std::move(state.history_spaces); @@ -1493,6 +1509,7 @@ class AmrProgramContext : public ProgramExecutionServices { std::uint64_t engine_topology_generation = 0; std::vector program_accepted_state; std::uint64_t program_accepted_state_revision = 0; + CellTemporalPartitionAcceptedState temporal_partition; std::set active_flux; std::map flux; std::map> flux_contributions; @@ -1714,6 +1731,7 @@ class AmrProgramContext : public ProgramExecutionServices { throw std::logic_error( "AMR Program accepted state changed while capturing an attempt snapshot"); snapshot.program_accepted_state_revision = accepted_revision; + snapshot.temporal_partition = temporal_partition_.checkpoint(); copy_set_in_place_(snapshot.active_flux, active_flux_ledger_); for (auto entry = snapshot.flux.begin(); entry != snapshot.flux.end();) { @@ -1829,6 +1847,8 @@ class AmrProgramContext : public ProgramExecutionServices { current_window_ = snapshot.window; current_sync_clock_ = snapshot.sync_clock; current_level_dt_ = snapshot.level_dt; + temporal_partition_.rollback(); + temporal_partition_.restore(snapshot.temporal_partition); } template @@ -3299,6 +3319,7 @@ class AmrProgramContext : public ProgramExecutionServices { mutable bool restart_regrid_prepared_ = false; mutable int automatic_regrid_macro_step_ = -1; mutable std::vector level_clocks_; + mutable BatchedCellTemporalPartition temporal_partition_; mutable std::uint64_t accepted_state_revision_ = 0; mutable std::uint64_t operator_topology_revision_counter_ = 0; mutable std::uint64_t observed_operator_topology_epoch_ = diff --git a/include/pops/runtime/program/cell_temporal_partition.hpp b/include/pops/runtime/program/cell_temporal_partition.hpp new file mode 100644 index 000000000..003c67540 --- /dev/null +++ b/include/pops/runtime/program/cell_temporal_partition.hpp @@ -0,0 +1,225 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pops::runtime::program { + +inline constexpr const char* kGlobalTemporalPartitionProvider = "pops.temporal-partition.global@1"; + +enum class TemporalPartitionKind : std::uint8_t { Global = 0, CellLocal = 1 }; + +/// Rank-independent identity and accepted logical clock of one cell. +/// +/// ``cell`` is a provider-owned canonical cell id within ``level``. It deliberately does not carry +/// an MPI rank or patch-local address, so ownership migration can rematerialize device storage from +/// the same accepted scientific image. +struct CellTemporalPartitionRecord { + int level = 0; + std::uint64_t cell = 0; + int rung = 0; + std::int64_t accepted_tick = 0; + + friend bool operator==(const CellTemporalPartitionRecord&, + const CellTemporalPartitionRecord&) = default; +}; + +/// Compact accepted-boundary image for a prepared temporal-partition provider. +/// +/// Physical time is represented by integer ticks over one provider-owned denominator. Accepted +/// checkpoints are synchronization barriers: every record must be at ``synchronization_tick``. +/// Attempt-local clocks live in ``BatchedCellTemporalPartition`` and can never leak into this image. +struct CellTemporalPartitionAcceptedState { + TemporalPartitionKind kind = TemporalPartitionKind::Global; + std::string provider_identity = kGlobalTemporalPartitionProvider; + std::uint64_t topology_epoch = 0; + std::int64_t synchronization_tick = 0; + std::int64_t tick_denominator = 1; + std::vector cells; + + friend bool operator==(const CellTemporalPartitionAcceptedState&, + const CellTemporalPartitionAcceptedState&) = default; +}; + +inline void validate_cell_temporal_partition_state( + const CellTemporalPartitionAcceptedState& state) { + if (state.provider_identity.empty()) + throw std::invalid_argument("temporal partition provider identity cannot be empty"); + if (state.synchronization_tick < 0 || state.tick_denominator <= 0) + throw std::invalid_argument( + "temporal partition accepted clock requires non-negative ticks and a positive denominator"); + if (state.kind == TemporalPartitionKind::Global) { + if (state.provider_identity != kGlobalTemporalPartitionProvider || state.topology_epoch != 0 || + !state.cells.empty()) + throw std::invalid_argument( + "global temporal partition state cannot carry topology or cell-local clocks"); + return; + } + if (state.kind != TemporalPartitionKind::CellLocal) + throw std::invalid_argument("temporal partition state has an unsupported kind"); + if (state.provider_identity == kGlobalTemporalPartitionProvider || state.cells.empty()) + throw std::invalid_argument( + "cell-local temporal partition state requires its prepared provider and cell clocks"); + + std::tuple previous{-1, 0}; + bool first = true; + for (const CellTemporalPartitionRecord& cell : state.cells) { + if (cell.level < 0 || cell.rung < 0 || cell.rung > 30 || cell.accepted_tick < 0) + throw std::invalid_argument("temporal partition cell record is outside its bounded domain"); + const std::tuple identity{cell.level, cell.cell}; + if (!first && !(previous < identity)) + throw std::invalid_argument( + "temporal partition cell records must be unique and canonically ordered"); + if (cell.accepted_tick != state.synchronization_tick) + throw std::invalid_argument( + "temporal partition accepted checkpoint is not at a synchronization barrier"); + const std::int64_t stride = std::int64_t{1} << cell.rung; + if (cell.accepted_tick % stride != 0) + throw std::invalid_argument( + "temporal partition accepted tick is not aligned to its cell rung"); + previous = identity; + first = false; + } +} + +/// Host authority for one bounded batch schedule and its transactional clocks. +/// +/// This class owns no numerical field and launches no per-cell task. A future Kokkos execution +/// provider consumes the canonical records in rung batches; this authority supplies exact attempt, +/// rollback, barrier, checkpoint and diagnostic semantics independently of the execution space. +class BatchedCellTemporalPartition { + public: + explicit BatchedCellTemporalPartition( + CellTemporalPartitionAcceptedState accepted = CellTemporalPartitionAcceptedState{}) + : accepted_(std::move(accepted)) { + validate_cell_temporal_partition_state(accepted_); + } + + const CellTemporalPartitionAcceptedState& accepted_state() const noexcept { return accepted_; } + bool attempt_active() const noexcept { return attempt_active_; } + + void begin_attempt(std::int64_t target_tick) { + if (attempt_active_) + throw std::logic_error("temporal partition attempt is already active"); + if (target_tick <= accepted_.synchronization_tick) + throw std::invalid_argument("temporal partition attempt target must advance accepted time"); + pending_ticks_.clear(); + pending_ticks_.reserve(accepted_.cells.size()); + for (const CellTemporalPartitionRecord& cell : accepted_.cells) { + const std::int64_t stride = std::int64_t{1} << cell.rung; + if ((target_tick - cell.accepted_tick) % stride != 0) + throw std::invalid_argument( + "temporal partition attempt target is unreachable for one prepared rung"); + pending_ticks_.push_back(cell.accepted_tick); + } + target_tick_ = target_tick; + attempt_active_ = true; + } + + /// Advance a canonically ordered batch of record indices belonging to exactly one rung. + void advance_batch(int rung, const std::vector& indices, std::int64_t target_tick) { + if (!attempt_active_) + throw std::logic_error("temporal partition batch requires an active attempt"); + if (indices.empty()) + throw std::invalid_argument("temporal partition batch cannot be empty"); + if (target_tick > target_tick_) + throw std::invalid_argument("temporal partition batch crosses its synchronization target"); + std::size_t previous = std::numeric_limits::max(); + for (std::size_t index : indices) { + if (index >= accepted_.cells.size() || + (previous != std::numeric_limits::max() && index <= previous)) + throw std::invalid_argument( + "temporal partition batch indices must be unique and canonically ordered"); + const CellTemporalPartitionRecord& cell = accepted_.cells[index]; + if (cell.rung != rung) + throw std::invalid_argument("temporal partition batch mixes prepared rungs"); + const std::int64_t stride = std::int64_t{1} << cell.rung; + if (target_tick <= pending_ticks_[index] || + (target_tick - pending_ticks_[index]) % stride != 0) + throw std::invalid_argument( + "temporal partition batch target is not a forward rung-aligned tick"); + previous = index; + } + for (std::size_t index : indices) + pending_ticks_[index] = target_tick; + } + + void require_barrier(const std::string& operation) const { + if (!attempt_active_) + return; + if (std::any_of(pending_ticks_.begin(), pending_ticks_.end(), + [this](std::int64_t tick) { return tick != target_tick_; })) + throw std::logic_error(operation + + " requires every cell-local clock at the synchronization barrier"); + } + + void commit() { + if (!attempt_active_) + throw std::logic_error("temporal partition commit requires an active attempt"); + require_barrier("temporal partition commit"); + for (std::size_t index = 0; index < accepted_.cells.size(); ++index) + accepted_.cells[index].accepted_tick = pending_ticks_[index]; + accepted_.synchronization_tick = target_tick_; + clear_attempt_(); + } + + void rollback() noexcept { clear_attempt_(); } + + CellTemporalPartitionAcceptedState checkpoint() const { + if (attempt_active_) + throw std::logic_error( + "temporal partition checkpoint requires an accepted synchronization barrier"); + return accepted_; + } + + void restore(CellTemporalPartitionAcceptedState accepted) { + if (attempt_active_) + throw std::logic_error("temporal partition restore cannot replace an active attempt"); + validate_cell_temporal_partition_state(accepted); + accepted_ = std::move(accepted); + } + + void require_global_execution_route() const { + if (accepted_.kind != TemporalPartitionKind::Global) + throw std::logic_error( + "cell-local temporal partition requires its prepared batched executor; the global AMR " + "step cannot silently replace it"); + } + + std::vector> manifest() const { + std::map rung_counts; + for (const CellTemporalPartitionRecord& cell : accepted_.cells) + ++rung_counts[cell.rung]; + std::vector> rows; + rows.push_back( + {"summary", accepted_.kind == TemporalPartitionKind::Global ? "global" : "cell_local", + accepted_.provider_identity, std::to_string(accepted_.topology_epoch), + std::to_string(accepted_.synchronization_tick), std::to_string(accepted_.tick_denominator), + std::to_string(accepted_.cells.size())}); + for (const auto& [rung, count] : rung_counts) + rows.push_back({"rung", std::to_string(rung), std::to_string(count)}); + return rows; + } + + private: + void clear_attempt_() noexcept { + pending_ticks_.clear(); + target_tick_ = 0; + attempt_active_ = false; + } + + CellTemporalPartitionAcceptedState accepted_; + std::vector pending_ticks_; + std::int64_t target_tick_ = 0; + bool attempt_active_ = false; +}; + +} // namespace pops::runtime::program diff --git a/include/pops/runtime/program/external_riemann_brick.hpp b/include/pops/runtime/program/external_riemann_brick.hpp index 3ebcaa630..26853aa2e 100644 --- a/include/pops/runtime/program/external_riemann_brick.hpp +++ b/include/pops/runtime/program/external_riemann_brick.hpp @@ -34,7 +34,7 @@ #include // build_block, block_n_ghost #include // dispatch_limiter: ONE limiter-route dispatch generator (ADC-640) #include // validate_limiter -#include // NoSlope / Minmod / VanLeer / Weno5 +#include // Prepared reconstruction policies #include // portable dlopen<->LoadLibraryW (ADC-99) diff --git a/include/pops/runtime/system.hpp b/include/pops/runtime/system.hpp index 70d687f0e..5cf6de6e5 100644 --- a/include/pops/runtime/system.hpp +++ b/include/pops/runtime/system.hpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include // POPS_EXPORT (methods resolved by the native loader through dlopen) #include // CoupledSourceProgram (facade POD, ADC-214) @@ -186,7 +187,8 @@ class System { /// Adds an equation block (one species). /// @param model composition of bricks (transport/source/elliptic + parameters) - /// @param limiter reconstruction: "none" | "minmod" | "vanleer" | "weno5" + /// @param limiter reconstruction: "none" | "minmod" | "vanleer" | "weno5" | "mc" | + /// "superbee" /// @param riemann numerical flux: "rusanov" (minimal generic) | "hll" (generic, requires /// model.wave_speeds) | "hllc" | "roe" (generic when the model supplies the /// HasHLLCStructure / HasRoeDissipation hooks; no layout inference or fallback) @@ -223,11 +225,10 @@ class System { /// rel_tol / abs_tol define the mandatory per-cell stopping criterion /// ||F||inf <= abs_tol + rel_tol*||F0||inf; fd_eps controls the finite-difference /// Jacobian and damping controls W -= damping*delta in (0, 1]. - /// @param newton_diagnostics IMEX only: enables the block's Newton report (max residual, - /// max iterations, failed cells -- non-finite / degenerate pivot / non-convergence), - /// aggregated over the substeps of each advance and available via newton_report(name). - /// OPT-IN: false (default) omits the retained diagnostic summary. Stays - /// flat (a separate bool, outside the homogeneous family of convergence options). + /// @param newton_diagnostics Reserved compatibility flag. The Program-only System runtime rejects + /// true until a typed implicit Program consumer actually publishes a Newton + /// report; accepting it would otherwise allocate a carrier that no execution + /// route writes. /// @param wave_speed_cache riemann='hll' + explicit ONLY: pre-computes model.wave_speeds once for /// every exact reconstructed face-trace pair, then reuses that interval from both /// adjacent residual cells. Net gain when wave_speeds is expensive (moment hierarchy). @@ -246,9 +247,9 @@ class System { double positivity_floor = 0.0, bool wave_speed_cache = false, double weno_epsilon = static_cast(kWenoEpsilon)); - /// Report of the implicit source Newton (IMEX) of a block, AGGREGATED over the substeps of the - /// LAST advance of the block. Only exists if the block was added with newton_diagnostics=true - /// (explicit error otherwise). Flat copy (no dependency on the numerics header). + /// Compatibility query for a report published by a typed implicit Program consumer. The current + /// Program-only System runtime rejects the opt-in request until such a consumer is installed. + /// Flat copy (no dependency on the numerics header). struct SourceNewtonReport { bool enabled; ///< a report was computed (at least one IMEX advance played) bool converged; ///< no failed cell on the last advance @@ -268,8 +269,9 @@ class System { /// real System context and installs a zero-copy native block. The complete canonical BindSchema /// vector crosses the fixed ABI once and is injected into the generated model before those closures /// are constructed. Package and module ABI keys must match. - /// @param limiter "none" | "minmod" | "vanleer" | "weno5" (weno5: add_compiled_model reallocates - /// the block state to block_n_ghost = 3 ghosts after install_block, like add_block) + /// @param limiter "none" | "minmod" | "vanleer" | "weno5" | "mc" | "superbee" + /// (weno5: add_compiled_model reallocates the block state to block_n_ghost = 3 + /// ghosts after install_block, like add_block) /// @param riemann "rusanov" | "hll" | "hllc" | "roe" /// @param recon "conservative" | "primitive" /// @param time "explicit" (SSPRK2) | "ssprk3" | "euler" | "imex" (the template marshals the explicit @@ -320,23 +322,33 @@ class System { POPS_EXPORT GridContext grid_context(const std::string& name); /// Index-qualified twin for an already authenticated Program block map. POPS_EXPORT GridContext grid_context(int block); - /// Install one executable built-in ghost plan. `face_types` is xlo,xhi,ylo,yhi using - /// periodic/foextrap/dirichlet; `face_values` is component-major (ncomp*4). + /// Install one executable built-in hyperbolic ghost plan. Face identities remain block/owner + /// qualified and component roles declare reflection behavior; no component index is interpreted. POPS_EXPORT void install_boundary_plan(const std::string& name, const std::string& identity, int required_depth, const std::vector& face_types, - const std::vector& face_values, int ncomp, + const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces = {}, const std::string& state_identity = {}, PreparedBoundaryReadDependencies read_dependencies = {}); - /// Exact-topology overload. The historical exported signature above remains available so - /// translation-only callers retain their ABI and execution path. + /// Exact-topology overload. Physical laws and component transforms remain model-aware; the + /// additional table only identifies periodic face pairs whose coordinate map is not the + /// axis-aligned translation represented by Periodicity. POPS_EXPORT void install_boundary_plan( const std::string& name, const std::string& identity, int required_depth, - const std::vector& face_types, const std::vector& face_values, int ncomp, + const std::vector& face_types, const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces, const std::string& state_identity, PreparedBoundaryReadDependencies read_dependencies, - std::vector periodic_identifications); + std::vector periodic_identifications, + const std::vector& face_representations = {}, + const std::vector& face_converter_identities = {}, + const std::vector>& face_analytic_opcodes = {}, + const std::vector>& face_analytic_literals = {}, + const std::vector& face_analytic_clocks = {}); /// Register the exact state Handle owned by a materialized block. This registry is independent /// of boundary plans: a block with periodic-only or no physical boundary remains a legal N-ary /// dependency of another block's boundary component. @@ -353,6 +365,9 @@ class System { POPS_EXPORT void install_ghost_boundary_component( const std::string& name, PreparedBoundaryComponentSpec spec, std::shared_ptr component); + POPS_EXPORT void install_boundary_flux_component( + const std::string& name, PreparedBoundaryComponentSpec spec, + std::shared_ptr component); POPS_EXPORT void install_field_boundary_residual_component( const std::string& name, PreparedBoundaryComponentSpec spec, std::shared_ptr component); @@ -382,7 +397,8 @@ class System { /// spatial stencil). WENO5 reads 3 ghosts, > the 2 allocated by install_block; called by add_compiled_model /// (header) with block_n_ghost(limiter) AFTER install_block, so the native compiled path /// (loader .so) accepts weno5 -- SAME mechanism as add_block. No-op if U already has enough ghosts - /// (none/minmod/vanleer, <= 2): allocation and data bit-identical to history. POPS_EXPORT: + /// (all catalogue routes with <= 2 ghosts): allocation and data bit-identical to history. + /// POPS_EXPORT: /// called by the header template add_compiled_model -> must be exported for the loader .so. POPS_EXPORT void set_block_ghosts(const std::string& name, int n_ghost); /// @} @@ -636,11 +652,13 @@ class System { /// arrays of ncomp doubles. Installed by install_block / add_compiled_model / push_dynamic from /// the block's model, consumed by set_primitive_state / get_primitive_state. using CellConvert = std::function; + /// Fallible conservative -> primitive conversion. A failed report forbids writing @p out. + using CellRecovery = std::function; /// Installs the pointwise cons <-> prim conversions of a block (after install_block). Called by /// the header template add_compiled_model (compiled model); the native path add_block and the dynamic /// .so path set them directly. POPS_EXPORT: resolved by the native loader through dlopen. POPS_EXPORT void set_block_conversion(const std::string& name, CellConvert prim_to_cons, - CellConvert cons_to_prim); + CellRecovery cons_to_prim); /// Installs the optional STEP BOUNDS of a block (after install_block): reduction of the /// max source frequency (HasSourceFrequency trait, bound dt <= cfl*substeps/(stride*mu)) and of the diff --git a/include/pops/runtime/system/system_block_store.hpp b/include/pops/runtime/system/system_block_store.hpp index ef8475925..b35f57f3f 100644 --- a/include/pops/runtime/system/system_block_store.hpp +++ b/include/pops/runtime/system/system_block_store.hpp @@ -5,7 +5,8 @@ #include // VariableSet (role descriptor carried by each block) #include // Box2D #include // device_fence (marshaling synchronizes the device before reading the host) -#include // MultiFab, Array4, ConstArray4 +#include // MultiFab, Array4, ConstArray4 +#include #include // GeometryMode + point-qualified geometry residuals #include @@ -56,6 +57,7 @@ class SystemBlockStore { /// arrays of ncomp doubles. SAME type as System::CellConvert (identical std::function): assignment /// from set_block_conversion / native_loader stays a trivial move. using CellConvert = std::function; + using CellRecovery = std::function; /// Compiled spatial closures frozen at block add time (composite model + spatial scheme). /// Type-erased ONLY at the block list level; the kernel stays compiled. @@ -97,7 +99,8 @@ class SystemBlockStore { // Set at add time (install_block / push_dynamic) from the real model; empty -> identity (the // model exposes no conversion, e.g. pure scalar or .so generated before this work). // Consumed by set_primitive_state / get_primitive_state (init/diagnostic in primitive). - CellConvert prim_to_cons, cons_to_prim; + CellConvert prim_to_cons; + CellRecovery cons_to_prim; // dt_hotspot DIAGNOSTIC (ADC-182): (U, w, i, j) -> GLOBAL cell dominating the transport CFL bound // of the block + its speed w = max(wx, wy). ON DEMAND only (System::dt_hotspot): // never queried by step/step_cfl (hot path bit-identical). Trailing + empty default. diff --git a/include/pops/runtime/system/system_diagnostics_registry.hpp b/include/pops/runtime/system/system_diagnostics_registry.hpp index e9e733d37..03df0dbcb 100644 --- a/include/pops/runtime/system/system_diagnostics_registry.hpp +++ b/include/pops/runtime/system/system_diagnostics_registry.hpp @@ -13,16 +13,15 @@ /// /// Extracted from three inline `std::map`s that lived on `System::Impl`. It groups the metadata a /// runtime report reads back: the effective numerical/physical block options captured at -/// configuration time and the OPT-IN Newton (IMEX) per-block reports. None of these are read by -/// SystemProgramDriver -> MockImpl-invisible. +/// configuration time and compatibility carriers for a future typed Program diagnostic consumer. +/// None of these are read by SystemProgramDriver -> MockImpl-invisible. /// /// OWNERSHIP CONTRACT /// - block_options: FROZEN AT BIND. Populated only by structural block installation, refused once /// bound, and read-only afterwards (effective_options_report). -/// - newton_reports: the map ENTRIES are frozen at bind (allocated by add_block for a block that -/// opted into diagnostics or a fail policy); the report CONTENTS are MUTABLE DURING RUN (the -/// block IMEX advance closures write into them by raw pointer each step). The shared_ptr gives a -/// STABLE address even when the map reallocates at a later add_block. +/// - newton_reports: compatibility storage for a typed Program consumer. The current Program-only +/// System runtime never allocates entries and rejects the public opt-in until such a consumer +/// owns publication. The shared_ptr preserves a stable address for that future seam. /// - NOT checkpointed: inspection metadata is re-derived by replaying the composition. /// /// KEY TYPING: keyed by the user-chosen BLOCK / STAGE NAME (no ADC-584 route id exists for a @@ -37,9 +36,8 @@ struct SystemDiagnosticsRegistry { /// Effective numerical/physical block options captured when the block/stage is added. The closures /// are opaque, so inspection stores the user-facing route decisions here. std::map block_options; - /// OPT-IN IMEX Newton report carrier reserved for the typed implicit Program primitive. Spatial - /// block closures never capture or write it. Absent (missing key) for a block without - /// newton_diagnostics -> newton_report raises a clear error. + /// Newton report carrier reserved for a typed implicit Program consumer. Spatial block closures + /// never capture or write it; the current runtime leaves the map empty and rejects the opt-in. std::map> newton_reports; /// Effective block options of @p name, or nullptr if the block was never registered. diff --git a/include/pops_headers.manifest b/include/pops_headers.manifest index 681943777..ee03efb4e 100644 --- a/include/pops_headers.manifest +++ b/include/pops_headers.manifest @@ -52,6 +52,7 @@ api pops/mesh/boundary/periodicity.hpp api pops/mesh/boundary/physical_bc.hpp api pops/mesh/boundary/prepared_boundary_component.hpp api pops/mesh/boundary/prepared_boundary_plan.hpp +api pops/mesh/boundary/prepared_hyperbolic_boundary.hpp api pops/mesh/execution/for_each.hpp api pops/mesh/geometry/geometry.hpp api pops/mesh/index/box2d.hpp @@ -107,6 +108,7 @@ api pops/numerics/fv/spatial_discretisation.hpp api pops/numerics/linalg/block_inverse.hpp api pops/numerics/linalg/dense_eig.hpp api pops/numerics/nonlinear/prepared_local_nonlinear.hpp +api pops/numerics/nonlinear/prepared_variable_recovery.hpp api pops/numerics/spatial/embedded_boundary/domain.hpp api pops/numerics/spatial/embedded_boundary/operator.hpp api pops/numerics/spatial/operators/cartesian_operator.hpp @@ -211,6 +213,7 @@ sdk-support pops/runtime/output_piece_collective.hpp sdk-support pops/runtime/program/amr_program_checkpoint.hpp sdk-root pops/runtime/program/amr_program_context.hpp sdk-support pops/runtime/program/cache_manager.hpp +sdk-support pops/runtime/program/cell_temporal_partition.hpp sdk-support pops/runtime/program/clock_schedule.hpp sdk-root pops/runtime/program/coeff_elliptic_ops.hpp sdk-root pops/runtime/program/component_package.hpp diff --git a/python/bindings/core/init/generated_component_invokers.inc b/python/bindings/core/init/generated_component_invokers.inc index 5dc15e864..e9aee010c 100644 --- a/python/bindings/core/init/generated_component_invokers.inc +++ b/python/bindings/core/init/generated_component_invokers.inc @@ -1,4 +1,4 @@ -// Generated by scripts/generate_component_catalog.py from catalog 5c67c081cf1808138583ed00856e6601c12384ae28e9c0f8cc7b8ce004c3b0f6; DO NOT EDIT. +// Generated by scripts/generate_component_catalog.py from catalog a10653b4730d0e5a8d8b1c21d3bb4263f3ca8fc93ebdfb1af59b88c7cbce07f0; DO NOT EDIT. // This file is the sole Python/native request marshaller. init_component_loader.cpp only registers it. #include diff --git a/python/bindings/core/init/init_amr.cpp b/python/bindings/core/init/init_amr.cpp index 324affaa5..6bb5a210b 100644 --- a/python/bindings/core/init/init_amr.cpp +++ b/python/bindings/core/init/init_amr.cpp @@ -234,19 +234,34 @@ void bind_amr_assembly(py::class_& cls) { "_install_boundary_plan", [](AmrSystem& system, const std::string& name, const std::string& identity, int required_depth, const std::vector& face_types, - const std::vector& face_values, int ncomp, + const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces, const std::string& state_identity, - const std::vector>& periodic_identifications) { + const std::vector>& periodic_identifications, + const std::vector& face_representations, + const std::vector& face_converter_identities, + const std::vector>& face_analytic_opcodes, + const std::vector>& face_analytic_literals, + const std::vector& face_analytic_clocks) { system.install_boundary_plan( - name, identity, required_depth, face_types, face_values, ncomp, - omitted_interface_faces, state_identity, PreparedBoundaryReadDependencies{}, - decode_periodic_identification_rows(periodic_identifications)); + name, identity, required_depth, face_types, face_values, face_identities, + component_roles, omitted_interface_faces, state_identity, + PreparedBoundaryReadDependencies{}, + decode_periodic_identification_rows(periodic_identifications), face_representations, + face_converter_identities, face_analytic_opcodes, face_analytic_literals, + face_analytic_clocks); }, py::arg("name"), py::arg("identity"), py::arg("required_depth"), py::arg("face_types"), - py::arg("face_values"), py::arg("ncomp"), + py::arg("face_values"), py::arg("face_identities"), py::arg("component_roles"), py::arg("omitted_interface_faces") = std::vector{}, py::arg("state_identity") = std::string{}, py::arg("periodic_identifications") = std::vector>{}, + py::arg("face_representations") = std::vector{}, + py::arg("face_converter_identities") = std::vector{}, + py::arg("face_analytic_opcodes") = std::vector>{}, + py::arg("face_analytic_literals") = std::vector>{}, + py::arg("face_analytic_clocks") = std::vector{}, "Install one resolved per-block ghost-production plan before lazy AMR construction.") .def("_install_block_state_route", &AmrSystem::install_block_state_route, py::arg("name"), py::arg("state_identity"), @@ -288,6 +303,20 @@ void bind_amr_assembly(py::class_& cls) { }, py::arg("name"), py::arg("component"), py::arg("binding"), py::arg("parameters_json"), py::arg("target_json"), py::arg("execution_context")) + .def( + "_install_boundary_flux_component", + [](AmrSystem& system, const std::string& name, + std::shared_ptr component, const py::dict& row, + const std::string& parameters_json, const std::string& target_json, + const py::dict& execution) { + system.install_boundary_flux_component( + name, + pops::python::detail::boundary_component_spec_from_python(row, parameters_json, + target_json, execution), + std::move(component)); + }, + py::arg("name"), py::arg("component"), py::arg("binding"), py::arg("parameters_json"), + py::arg("target_json"), py::arg("execution_context")) .def( "_install_field_boundary_residual_component", [](AmrSystem& system, const std::string& name, @@ -824,6 +853,7 @@ void bind_amr_program(py::class_& cls) { py::arg("payload"), py::arg("names"), py::arg("depths"), py::arg("ncomps")) .def("program_accepted_state_manifest", &AmrSystem::program_accepted_state_manifest) .def("program_clock_manifest", &AmrSystem::program_clock_manifest) + .def("program_temporal_partition_manifest", &AmrSystem::program_temporal_partition_manifest) .def("program_flux_ledger_manifest", &AmrSystem::program_flux_ledger_manifest) .def("program_interface_flux_ledger_manifest", &AmrSystem::program_interface_flux_ledger_manifest) diff --git a/python/bindings/core/init/init_system.cpp b/python/bindings/core/init/init_system.cpp index 40153d799..67635e887 100644 --- a/python/bindings/core/init/init_system.cpp +++ b/python/bindings/core/init/init_system.cpp @@ -146,8 +146,8 @@ void bind_system_assembly(py::class_& cls) { // bit-identical. Resolved on the C++ side against the block's names/roles (error on a missing name/role). py::arg("implicit_vars") = std::vector{}, py::arg("implicit_roles") = std::vector{}, - // Options of the implicit IMEX source Newton. newton_diagnostics=True enables the report - // (newton_report(name)). + // Options of the implicit IMEX source Newton. The Program-only System runtime rejects + // newton_diagnostics=True until a typed consumer actually publishes that report. py::arg("newton_max_iters") = kNewtonDefaultMaxIters, py::arg("newton_rel_tol") = static_cast(kNewtonDefaultRelTol), py::arg("newton_abs_tol") = static_cast(kNewtonDefaultAbsTol), @@ -169,19 +169,34 @@ void bind_system_assembly(py::class_& cls) { "_install_boundary_plan", [](System& system, const std::string& name, const std::string& identity, int required_depth, const std::vector& face_types, - const std::vector& face_values, int ncomp, + const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces, const std::string& state_identity, - const std::vector>& periodic_identifications) { + const std::vector>& periodic_identifications, + const std::vector& face_representations, + const std::vector& face_converter_identities, + const std::vector>& face_analytic_opcodes, + const std::vector>& face_analytic_literals, + const std::vector& face_analytic_clocks) { system.install_boundary_plan( - name, identity, required_depth, face_types, face_values, ncomp, - omitted_interface_faces, state_identity, PreparedBoundaryReadDependencies{}, - decode_periodic_identification_rows(periodic_identifications)); + name, identity, required_depth, face_types, face_values, face_identities, + component_roles, omitted_interface_faces, state_identity, + PreparedBoundaryReadDependencies{}, + decode_periodic_identification_rows(periodic_identifications), face_representations, + face_converter_identities, face_analytic_opcodes, face_analytic_literals, + face_analytic_clocks); }, py::arg("name"), py::arg("identity"), py::arg("required_depth"), py::arg("face_types"), - py::arg("face_values"), py::arg("ncomp"), + py::arg("face_values"), py::arg("face_identities"), py::arg("component_roles"), py::arg("omitted_interface_faces") = std::vector{}, py::arg("state_identity") = std::string{}, py::arg("periodic_identifications") = std::vector>{}, + py::arg("face_representations") = std::vector{}, + py::arg("face_converter_identities") = std::vector{}, + py::arg("face_analytic_opcodes") = std::vector>{}, + py::arg("face_analytic_literals") = std::vector>{}, + py::arg("face_analytic_clocks") = std::vector{}, "Install one resolved per-block ghost-production plan before block construction.") .def("_install_block_state_route", &System::install_block_state_route, py::arg("name"), py::arg("state_identity"), @@ -205,6 +220,20 @@ void bind_system_assembly(py::class_& cls) { }, py::arg("name"), py::arg("component"), py::arg("binding"), py::arg("parameters_json"), py::arg("target_json"), py::arg("execution_context")) + .def( + "_install_boundary_flux_component", + [](System& system, const std::string& name, + std::shared_ptr component, const py::dict& row, + const std::string& parameters_json, const std::string& target_json, + const py::dict& execution) { + system.install_boundary_flux_component( + name, + pops::python::detail::boundary_component_spec_from_python(row, parameters_json, + target_json, execution), + std::move(component)); + }, + py::arg("name"), py::arg("component"), py::arg("binding"), py::arg("parameters_json"), + py::arg("target_json"), py::arg("execution_context")) .def( "_install_field_boundary_residual_component", [](System& system, const std::string& name, @@ -253,9 +282,7 @@ void bind_system_assembly(py::class_& cls) { py::arg("level") = 0) .def("_discard_interface_flux_components", &System::discard_interface_flux_components, "Roll back one failed post-block interface authority transaction.") - // Newton report (IMEX diagnostics OPT-IN): dict {enabled, converged, max_residual, - // max_iters_used, n_failed, failed_cell, failed_component}, aggregated over the substeps of the - // LAST advance of the block. failed_cell = (i, j) of ONE faulty cell or None. + // Compatibility query for a Newton report published by a typed implicit Program consumer. .def( "newton_report", [](const System& s, const std::string& name) { diff --git a/python/pops/_capabilities_report.py b/python/pops/_capabilities_report.py index b9af2166c..2d7d47e93 100644 --- a/python/pops/_capabilities_report.py +++ b/python/pops/_capabilities_report.py @@ -374,6 +374,182 @@ def _python_contract_rows(flags: Any, source: str) -> list[Any]: mpi = bool(_flag_value(flags, "supports_mpi")) gpu = bool(_flag_value(flags, "supports_gpu")) return [ + _row( + "boundary:prepared_transport", + layout="uniform|amr", + backend="production", + platform="host", + mpi=mpi, + gpu=gpu, + status="partial", + limitation=( + "one prepared 2D model-aware plan serves Uniform/AMR native and compiled " + "transport boundaries; executable built-ins are periodic, extrapolation, " + "constant/RuntimeParam fixed state, conservative device-side analytic " + "(x,y,t,params) fixed state, model primitive-to-conservative fixed-state conversion, " + "and typed-role slip wall; dynamic AMR regrid keeps internal " + "coarse-fine ghosts under the prepared transfer authority on MPI ranks, with " + "double-physical corners explicitly not required by dimension-split FV stencils; " + "numerical resolution rejects every descriptor outside this executable envelope" + ), + source=source, + ), + _row( + "boundary:characteristic_no_inflow", + layout="uniform|amr", + backend="none", + platform="host", + mpi=mpi, + gpu=gpu, + status="unavailable", + limitation=( + "numerical resolution rejects characteristic closure until executable model " + "eigenstructure, incoming-mode data, and sonic/sign policies are installed" + ), + requested="characteristic no-inflow/outflow transport boundary", + available_route="explicit fixed-state inflow or extrapolated outflow", + alternative=( + "use the explicit built-in route or install a prepared characteristic kernel" + ), + source=source, + ), + _row( + "boundary:representation_conversion", + layout="uniform|amr", + backend="production", + platform="host", + mpi=mpi, + gpu=gpu, + status="partial", + limitation=( + "2D fixed-state primitive inflow may use the exact compiled block-model " + "to_conservative provider; conservative-to-primitive recovery and arbitrary " + "representation converters remain unavailable, and conversion does not invent " + "a boundary admissibility projection" + ), + source=source, + ), + _row( + "boundary:analytic_xtp", + layout="uniform|amr", + backend="production", + platform="host", + mpi=mpi, + gpu=gpu, + status="partial", + limitation=( + "2D conservative fixed-state inflow accepts data-only analytic ScalarExpr " + "programs over typed coordinates, one exact logical Clock, and bound parameters; " + "primitive per-point conversion and discrete state/field/input reads remain " + "unavailable, analytic ghost depth may not exceed the normal domain extent, and " + "axis-permuted periodic coordinates require a prepared coordinate map" + ), + source=source, + ), + _row( + "boundary:post_riemann_flux", + layout="uniform|amr", + backend="production", + platform="host", + mpi=mpi, + gpu=False, + status="partial", + limitation=( + "one typed BoundaryFlux component transforms the already evaluated outward-normal " + "face flux between the Riemann solve and divergence/reflux through the same " + "prepared Uniform/AMR boundary plan; execution is currently a 2D Cartesian " + "host-batch route, the ordinary Uniform route materializes face fields when this " + "stage is selected, and no device-native or embedded/cut-cell metric ABI or " + "high-level TransportBoundarySet convenience exists yet" + ), + requested="post-Riemann transport-boundary flux provider", + available_route="PostRiemannFlux plus one qualified BoundaryFlux component", + source=source, + ), + _row( + "riemann:typed_failure_outcome", + layout="uniform|amr", + backend="production", + platform="host", + mpi=mpi, + gpu=gpu, + status="partial", + limitation=( + "Rusanov, HLL, HLLC, and Roe return one device-copyable FluxEvaluation with " + "typed status, stability bound, and reason code; face failures are reduced and " + "reject the owning transaction, but no fallback solver can be selected" + ), + source=source, + ), + _row( + "riemann:prepared_recovery_policy", + layout="uniform|amr", + backend="none", + platform="host", + mpi=mpi, + gpu=gpu, + status="unavailable", + limitation=( + "there is no prepared ordered Riemann recovery chain, requested-versus-used " + "solver outcome, block counter, or restart metadata; a typed candidate failure " + "rejects the step and never substitutes another solver" + ), + requested=( + "prepared Riemann recovery chain with requested/used solver diagnostics" + ), + available_route=( + "one explicitly selected Riemann solver with typed rejection and transactional " + "rollback" + ), + alternative=( + "select one supported Riemann route explicitly and consume rejection through " + "the step retry/failure policy" + ), + source=source, + ), + _row( + "recovery:prepared_variable", + layout="uniform|amr", + backend="production", + platform="host", + mpi=mpi, + gpu=gpu, + status="partial", + limitation=( + "one block-prepared closed-form method returns a device-copyable " + "RecoveryOutcome/RecoveryReport retaining selected and last-attempted method " + "kinds across type erasure; System conservative-to-primitive " + "materialization and Cartesian, polar, masked, and embedded-boundary face " + "reconstruction consume publication permission before copying or flux " + "evaluation, with no implicit repair, fallback, or mutable cache" + ), + source=source, + ), + _row( + "recovery:complete_consumer_cutover", + layout="uniform|amr", + backend="none", + platform="host", + mpi=mpi, + gpu=gpu, + status="unavailable", + limitation=( + "initial and analytic materialization, model/source conversion, AMR " + "transfer/regrid, primitive boundary traces, fallible primitive-to-conservative " + "conversion, persistent warm starts, cache restart, and the backend/performance " + "matrix do not yet share one prepared recovery authority" + ), + requested="complete prepared variable-recovery consumer cutover", + available_route=( + "prepared closed-form recovery for System materialization and spatial face " + "reconstruction" + ), + alternative=( + "use the delivered conservative-to-primitive consumers or implement the missing " + "fallible provider and cache/restart contracts" + ), + source=source, + ), _row( "amr:external_field_solver_v2", layout="amr", @@ -395,6 +571,25 @@ def _python_contract_rows(flags: Any, source: str) -> list[Any]: ), source=source, ), + _row( + "amr:field_coupled_rhs_jacvec", + layout="amr", + backend="none", + platform="host", + mpi=mpi, + gpu=gpu, + status="unavailable", + limitation=( + "field-coupled rhs_jacvec has no level-qualified tangent-field provider ABI " + "for AMR level > 0" + ), + requested="field_coupled rhs_jacvec on AMR level > 0", + available_route="field_coupled rhs_jacvec on AMR level 0", + alternative=( + "use the level-0 route or implement a level-qualified tangent-field provider ABI" + ), + source=source, + ), _row( "amr:source_implicit_program", layout="amr", @@ -681,7 +876,7 @@ def _inventory_rows(flags: Any, source: Any) -> list: "reconstruction:muscl", layout="uniform|amr", backend="production", - limitation="ghost_depth=2; native limiters minmod/vanleer", + limitation="ghost_depth=2; native limiters minmod/vanleer/mc/superbee", source=source, ), _row( @@ -697,23 +892,15 @@ def _inventory_rows(flags: Any, source: Any) -> list: _row( "limiter:mc", layout="uniform|amr", - backend="none", - status="unavailable", - limitation="catalogued but no native C++ limiter symbol exists", - requested="limiter=MC()", - available_route="Minmod() or VanLeer()", - alternative="use pops.numerics.reconstruction.limiters.Minmod()", + backend="production", + limitation="native POPS_HD MC slope policy; formal_order=2; ghost_depth=2", source=source, ), _row( "limiter:superbee", layout="uniform|amr", - backend="none", - status="unavailable", - limitation="catalogued but no native C++ limiter symbol exists", - requested="limiter=Superbee()", - available_route="Minmod() or VanLeer()", - alternative="use pops.numerics.reconstruction.limiters.VanLeer()", + backend="production", + limitation="native POPS_HD Superbee slope policy; formal_order=2; ghost_depth=2", source=source, ), _row( diff --git a/python/pops/_generated_component_interfaces.py b/python/pops/_generated_component_interfaces.py index 3bb1c5fc6..fc193e650 100644 --- a/python/pops/_generated_component_interfaces.py +++ b/python/pops/_generated_component_interfaces.py @@ -3,8 +3,8 @@ NATIVE_COMPONENT_ABI_VERSION = 1 NATIVE_COMPONENT_COMMON_ABI_VERSION = 1 -NATIVE_COMPONENT_CATALOG_SHA256 = '5c67c081cf1808138583ed00856e6601c12384ae28e9c0f8cc7b8ce004c3b0f6' -NATIVE_COMPONENT_CATALOG_SEMANTIC_SHA256 = 'adbb3693dc17eff5aa7b78415df35f011dfd2c64fc26eb9a98200923e52c47ea' +NATIVE_COMPONENT_CATALOG_SHA256 = 'a10653b4730d0e5a8d8b1c21d3bb4263f3ca8fc93ebdfb1af59b88c7cbce07f0' +NATIVE_COMPONENT_CATALOG_SEMANTIC_SHA256 = '9d7d38624833a7a7d7462ac7113bbdc215e6bce145ad1150b8cc1decf2d297b1' NATIVE_TAGGING_PROGRAM_ABI = {'version': 1, 'execution_modes': {'native_backend': 1, 'host': 2}, 'collective_scopes': {'none': 0}, @@ -72,6 +72,14 @@ 'hot_path': True, 'facets': ('stencil', 'lowering'), 'operations': ('apply',)}, + {'id': 6, + 'name': 'boundary_flux', + 'uri': 'pops://interfaces/boundary-flux', + 'version': 1, + 'cpp_table': 'PopsBoundaryFluxApiV1', + 'hot_path': True, + 'facets': ('provider', 'lowering', 'fallible_evaluation'), + 'operations': ('transform_faces',)}, {'id': 7, 'name': 'field_solver', 'uri': 'pops://interfaces/field-solver', @@ -102,6 +110,7 @@ 'corner_resolver': ('ghost_boundary', 'apply_region_batch'), 'numerical_closure': ('ghost_boundary', 'apply_region_batch'), 'conservative_flux': ('numerical_flux', 'evaluate_faces'), + 'boundary_flux_provider': ('boundary_flux', 'transform_faces'), 'residual_operator': ('field_boundary_closure', 'residual'), 'linearization_operator': ('field_boundary_closure', 'jvp')} diff --git a/python/pops/analytic/__init__.py b/python/pops/analytic/__init__.py index 06f9fbe0b..cca94efe8 100644 --- a/python/pops/analytic/__init__.py +++ b/python/pops/analytic/__init__.py @@ -25,6 +25,7 @@ radius, sin, sqrt, + time, where, x, y, @@ -69,6 +70,7 @@ "radius", "sin", "sqrt", + "time", "where", "x", "y", diff --git a/python/pops/analytic/_functions.py b/python/pops/analytic/_functions.py index bbb1a5269..d830106b5 100644 --- a/python/pops/analytic/_functions.py +++ b/python/pops/analytic/_functions.py @@ -15,6 +15,7 @@ constant, parameter, _program_input, + _time, ) @@ -36,6 +37,12 @@ def input(value_id: Any, component: Any) -> ScalarExpr: return _program_input(value_id, component) +def time(clock: Any) -> ScalarExpr: + """Read physical time from one exact logical ``Clock`` at native evaluation.""" + + return _time(clock) + + def x(frame: Any) -> ScalarExpr: """Return the typed x coordinate of ``frame``.""" @@ -210,6 +217,7 @@ def where(predicate: Any, when_true: Any, when_false: Any) -> ScalarExpr: "radius", "sin", "sqrt", + "time", "where", "x", "y", diff --git a/python/pops/analytic/_model.py b/python/pops/analytic/_model.py index 9ba6acd7f..953bd1f1f 100644 --- a/python/pops/analytic/_model.py +++ b/python/pops/analytic/_model.py @@ -26,7 +26,8 @@ _SCALAR_BINARY_OPS = frozenset({ "add", "sub", "mul", "div", "pow", "atan2", "hypot", "minimum", "maximum", }) -_SCALAR_OPS = frozenset({"constant", "coordinate", "parameter", "input", "where"}) | _SCALAR_UNARY_OPS \ +_SCALAR_OPS = frozenset({"constant", "coordinate", "parameter", "input", "time", "where"}) \ + | _SCALAR_UNARY_OPS \ | _SCALAR_BINARY_OPS _COMPARISON_OPS = frozenset({"eq", "ne", "lt", "le", "gt", "ge"}) _LOGICAL_BINARY_OPS = frozenset({"and", "or"}) @@ -84,6 +85,19 @@ def __post_init__(self) -> None: raise TypeError("analytic input component must be canonical non-empty text") +@dataclass(frozen=True, slots=True) +class _TimeRef: + """Exact logical clock read consumed as physical time by a prepared runtime.""" + + clock: Any + + def __post_init__(self) -> None: + from pops.time import Clock + + if type(self.clock) is not Clock or self.clock.owner is None: + raise TypeError("analytic time requires one owner-qualified exact Clock") + + @dataclass(frozen=True, slots=True, eq=False, init=False) class ScalarExpr: """One immutable scalar analytic expression. @@ -99,6 +113,7 @@ class ScalarExpr: _coordinate: _CoordinateRef | None _parameter: Any _input: _InputRef | None + _time: _TimeRef | None _frame_id: str | None __hash__: ClassVar[None] = None __pops_ir_immutable__: ClassVar[bool] = True @@ -118,6 +133,7 @@ def _new( coordinate: _CoordinateRef | None = None, parameter: Any = None, input_ref: _InputRef | None = None, + time_ref: _TimeRef | None = None, ) -> ScalarExpr: result = object.__new__(cls) object.__setattr__(result, "_op", op) @@ -126,6 +142,7 @@ def _new( object.__setattr__(result, "_coordinate", coordinate) object.__setattr__(result, "_parameter", parameter) object.__setattr__(result, "_input", input_ref) + object.__setattr__(result, "_time", time_ref) object.__setattr__(result, "_frame_id", _merged_frame_id(arguments, coordinate)) _validate_scalar_local(result) return result @@ -277,6 +294,11 @@ def input_references(self) -> tuple[tuple[int, str], ...]: return _input_references(self) + def time_clocks(self) -> tuple[Any, ...]: + """Return exact logical clocks in deterministic first-occurrence order.""" + + return _time_clocks(self) + @dataclass(frozen=True, slots=True, eq=False, init=False) class PredicateExpr: @@ -458,6 +480,12 @@ def _program_input(value_id: Any, component: Any) -> ScalarExpr: return ScalarExpr._new("input", input_ref=_InputRef(value_id, component)) +def _time(clock: Any) -> ScalarExpr: + """Internal exact-clock constructor exposed through :func:`pops.analytic.time`.""" + + return ScalarExpr._new("time", time_ref=_TimeRef(clock)) + + def _as_scalar(value: Any, *, where: str = "analytic scalar") -> ScalarExpr: if isinstance(value, ScalarExpr): return value @@ -542,30 +570,42 @@ def _validate_scalar_local(value: ScalarExpr) -> None: raise TypeError("analytic node arguments must be an immutable tuple") if value._op == "constant": if value._arguments or value._coordinate is not None or value._parameter is not None \ - or value._input is not None \ + or value._input is not None or value._time is not None \ or type(value._literal) is not float: raise TypeError("analytic constant node has an invalid shape") _finite_literal(value._literal) elif value._op == "coordinate": if value._arguments or value._literal is not None \ or value._parameter is not None \ - or value._input is not None \ + or value._input is not None or value._time is not None \ or not isinstance(value._coordinate, _CoordinateRef): raise TypeError("analytic coordinate node has an invalid shape") elif value._op == "parameter": from pops.model import ParamHandle if value._arguments or value._literal is not None or value._coordinate is not None \ - or value._input is not None \ + or value._input is not None or value._time is not None \ or type(value._parameter) is not ParamHandle: raise TypeError("analytic parameter node has an invalid shape") elif value._op == "input": if value._arguments or value._literal is not None or value._coordinate is not None \ - or value._parameter is not None or not isinstance(value._input, _InputRef): + or value._parameter is not None or value._time is not None \ + or not isinstance(value._input, _InputRef): raise TypeError("analytic input node has an invalid shape") + elif value._op == "time": + if ( + value._arguments + or value._literal is not None + or value._coordinate is not None + or value._parameter is not None + or value._input is not None + or not isinstance(value._time, _TimeRef) + ): + raise TypeError("analytic time node has an invalid shape") else: if value._literal is not None or value._coordinate is not None \ - or value._parameter is not None or value._input is not None: + or value._parameter is not None or value._input is not None \ + or value._time is not None: raise TypeError( "analytic operator node cannot carry literal, coordinate or parameter metadata") expected = 1 if value._op in _SCALAR_UNARY_OPS else 2 @@ -727,6 +767,15 @@ def _node_to_data(value: Expression) -> dict[str, Any]: "value_id": value._input.value_id, "component": value._input.component, } + if value._op == "time": + if value._time is None: + raise TypeError("analytic time node is missing its exact Clock") + return { + "kind": "scalar", + "op": "time", + "clock": value._time.clock.to_data(), + "clock_id": value._time.clock.qualified_id, + } return { "kind": "scalar", "op": value._op, @@ -841,12 +890,21 @@ def _node_from_data( raise TypeError("analytic input data has an unsupported shape") return ScalarExpr._new( "input", input_ref=_InputRef(data["value_id"], data["component"])) + if expected == "scalar" and op == "time": + if set(data) != {"kind", "op", "clock", "clock_id"}: + raise TypeError("analytic time data has an unsupported shape") + from pops.time import Clock + + clock = Clock.from_data(data["clock"]) + if data["clock_id"] != clock.qualified_id: + raise ValueError("analytic time data changed its exact Clock identity") + return ScalarExpr._new("time", time_ref=_TimeRef(clock)) if set(data) != {"kind", "op", "arguments"} \ or not isinstance(data["arguments"], list): raise TypeError("analytic operator data has an unsupported shape") raw_arguments = data["arguments"] if expected == "scalar": - if op not in _SCALAR_OPS - {"constant", "coordinate", "parameter", "input"}: + if op not in _SCALAR_OPS - {"constant", "coordinate", "parameter", "input", "time"}: raise ValueError("unsupported analytic scalar operation %r" % op) child_kinds = (["predicate", "scalar", "scalar"] if op == "where" else ["scalar"] * len(raw_arguments)) @@ -886,7 +944,7 @@ def _resolve_references(value: Expression, resolver: Any) -> Expression: if resolved.param_kind != value._parameter.param_kind: raise ValueError("analytic parameter resolver changed the declared parameter kind") return ScalarExpr._new("parameter", parameter=resolved) - if value._op in {"constant", "coordinate", "input"}: + if value._op in {"constant", "coordinate", "input", "time"}: return value return ScalarExpr._new( value._op, @@ -949,6 +1007,26 @@ def _input_references(value: Expression) -> tuple[tuple[int, str], ...]: return tuple(ordered) +def _time_clocks(value: Expression) -> tuple[Any, ...]: + """Collect exact Clock values without assigning a native runtime slot.""" + + ordered: list[Any] = [] + seen: set[str] = set() + stack = [value] + while stack: + node = stack.pop() + if isinstance(node, ScalarExpr) and node._op == "time": + reference = node._time + if not isinstance(reference, _TimeRef): + raise TypeError("analytic time leaf does not carry an exact _TimeRef") + identity = reference.clock.qualified_id + if identity not in seen: + seen.add(identity) + ordered.append(reference.clock) + stack.extend(reversed(node._arguments)) + return tuple(ordered) + + __all__ = [ "AnalyticTruthValueError", "DEFAULT_MAX_DEPTH", @@ -959,5 +1037,6 @@ def _input_references(value: Expression) -> tuple[tuple[int, str], ...]: "SCHEMA_VERSION", "ScalarExpr", "_program_input", + "_time", "parameter", ] diff --git a/python/pops/boundary/__init__.py b/python/pops/boundary/__init__.py index cadd68d55..8e0849939 100644 --- a/python/pops/boundary/__init__.py +++ b/python/pops/boundary/__init__.py @@ -7,6 +7,8 @@ from .transport import ( BoundaryStencilRequirement, + model_primitive_to_conservative, + SlipWall, TransportBoundarySet, ) from .embedded import EmbeddedBoundaryFlux, ZeroFlux @@ -14,6 +16,8 @@ __all__ = [ "BoundaryStencilRequirement", "EmbeddedBoundaryFlux", + "model_primitive_to_conservative", + "SlipWall", "TransportBoundarySet", "ZeroFlux", ] diff --git a/python/pops/boundary/transport.py b/python/pops/boundary/transport.py index f466f720f..c17788328 100644 --- a/python/pops/boundary/transport.py +++ b/python/pops/boundary/transport.py @@ -5,8 +5,9 @@ from dataclasses import dataclass import hashlib import json -from typing import Any, ClassVar +from typing import Any, ClassVar, cast +from pops.analytic import ScalarExpr from pops.domain import DomainBoundary from pops._ir import Expr from pops._ir.expr import Const @@ -31,8 +32,13 @@ def _expression(value: Any, *, where: str) -> Expr: raise TypeError("%s must be a PoPS Expr or an exact scalar" % where) from exc -def _expression_data(value: Expr, *, qualified: bool = False) -> Any: +def _expression_data(value: Expr | ScalarExpr, *, qualified: bool = False) -> Any: """Return the same stable structural protocol used by derived parameter expressions.""" + if isinstance(value, ScalarExpr): + return { + "protocol": "pops.analytic.scalar.v1", + "value": value.to_data(), + } if qualified: from pops.model._bind_expression import qualified_expression_key @@ -75,6 +81,27 @@ def _converter(value: Any) -> Handle | None: return value +def model_primitive_to_conservative(state: Any) -> Handle: + """Return the exact block-model primitive-to-conservative boundary provider. + + The returned Handle names the already compiled ``Model.to_conservative`` kernel; it is not a + Python callback and it cannot select an unrelated conversion implementation by string. The + corresponding ``Inflow.value`` tuple follows the model's declared primitive-variable order. + """ + checked = _state(state, where="model_primitive_to_conservative.state") + representation = getattr(getattr(checked, "space", None), "representation", None) + if representation != "conservative": + raise ValueError( + "model_primitive_to_conservative requires a conservative target state" + ) + digest = hashlib.sha256(checked.qualified_id.encode("utf-8")).hexdigest()[:24] + return Handle( + "model-primitive-to-conservative-%s" % digest, + kind="representation_conversion", + owner=checked.owner_path, + ) + + def _condition_protocol(value: Any, *, where: str) -> Any: _state(getattr(value, "state", None), where="%s.state" % where) for method in ("inspect", "resolve_references", "resolve_condition"): @@ -119,13 +146,37 @@ def _provider_handle(state: Handle, boundary: Any, condition_type: str) -> Handl ) +def _analytic_time_handle(clock: Any) -> Handle: + from pops.time import Clock + + if type(clock) is not Clock or clock.owner is None: + raise TypeError("analytic boundary time requires one owner-qualified exact Clock") + digest = hashlib.sha256(clock.qualified_id.encode("utf-8")).hexdigest()[:24] + return Handle( + "clock-%s" % digest, + kind="time", + owner=clock.owner, + ) + + def _dependency_handles( - values: tuple[Expr, ...], *, include_state: Handle | None = None + values: tuple[Expr | ScalarExpr, ...], *, include_state: Handle | None = None ) -> tuple[tuple[Handle, ...], tuple[Handle, ...], tuple[Handle, ...], tuple[ParamHandle, ...]]: - references = _unique_references(*(value.declaration_references() for value in values)) + references = _unique_references( + *( + value.declaration_references() if isinstance(value, Expr) else value.parameter_handles() + for value in values + ) + ) states = [reference for reference in references if reference.kind == "state"] fields = [reference for reference in references if reference.kind == "field"] time = [reference for reference in references if reference.kind == "time"] + for value in values: + if isinstance(value, ScalarExpr): + for clock in value.time_clocks(): + handle = _analytic_time_handle(clock) + if handle not in time: + time.append(handle) params = [ reference for reference in references if isinstance(reference, ParamHandle) and reference.param_kind == "runtime" @@ -202,26 +253,43 @@ class ResolvedTransportCondition: geometry: DomainBoundary condition_type: str state: Handle - values: tuple[Expr, ...] + values: tuple[Expr | ScalarExpr, ...] requirement: BoundaryStencilRequirement provider: Any def __post_init__(self) -> None: - from pops.mesh.boundaries import BoundaryProvider + from pops.mesh.boundaries import BoundaryProvider, BoundaryProviderKind if not isinstance(self.geometry, DomainBoundary): raise TypeError("ResolvedTransportCondition.geometry must be a DomainBoundary") - if self.condition_type not in {"inflow", "outflow"}: + if self.condition_type not in {"inflow", "outflow", "slip_wall"}: raise ValueError("unsupported built-in transport condition type") _state(self.state, where="ResolvedTransportCondition.state") if not self.state.is_resolved: raise TypeError("ResolvedTransportCondition.state must be canonical") - if not isinstance(self.values, tuple) or any(not isinstance(row, Expr) for row in self.values): - raise TypeError("ResolvedTransportCondition.values must contain Expr values") + if not isinstance(self.values, tuple) \ + or any(not isinstance(row, (Expr, ScalarExpr)) for row in self.values): + raise TypeError("ResolvedTransportCondition.values must contain Expr or ScalarExpr values") if self.requirement.state != self.state: raise ValueError("transport condition and stencil requirement refer to different states") if not isinstance(self.provider, BoundaryProvider): raise TypeError("ResolvedTransportCondition.provider must be a BoundaryProvider") + allowed_kinds = { + "inflow": frozenset(( + BoundaryProviderKind.INFLOW, + BoundaryProviderKind.DIRECTIONAL_TRANSPORT, + )), + "outflow": frozenset(( + BoundaryProviderKind.OUTFLOW, + BoundaryProviderKind.DIRECTIONAL_TRANSPORT, + )), + "slip_wall": frozenset((BoundaryProviderKind.GHOST_FORMULA,)), + }[self.condition_type] + if self.provider.kind not in allowed_kinds: + raise ValueError( + "transport condition %r cannot use boundary provider law %r" + % (self.condition_type, self.provider.kind.value) + ) def canonical_identity(self) -> dict[str, Any]: return { @@ -248,6 +316,7 @@ def _resolved_condition( ) -> ResolvedTransportCondition: from pops.mesh.boundaries import ( BoundaryDependencies, + GhostFormula, GhostState, Inflow as LowLevelInflow, Outflow as LowLevelOutflow, @@ -275,7 +344,11 @@ def _resolved_condition( characteristic=_closure(), ) output = GhostState(boundary=boundary, subject=state, representation=target) - factory = LowLevelInflow if condition_type == "inflow" else LowLevelOutflow + factory = { + "inflow": LowLevelInflow, + "outflow": LowLevelOutflow, + "slip_wall": GhostFormula, + }[condition_type] provider = factory( handle=_provider_handle(state, geometry, condition_type), outputs=(output,), @@ -297,7 +370,7 @@ class Inflow: condition_type: ClassVar[str] = "inflow" state: Handle - values: tuple[Expr, ...] + values: tuple[Expr | ScalarExpr, ...] representation: Representation | None converter: Handle | None @@ -315,11 +388,34 @@ def __init__( raw_values = value if isinstance(value, tuple) else (value,) if not raw_values: raise ValueError("Inflow.value must prescribe at least one state component") + analytic = any(isinstance(row, ScalarExpr) for row in raw_values) + if analytic: + from pops.analytic import constant as analytic_constant + + checked_values = [] + for index, row in enumerate(raw_values): + if isinstance(row, ScalarExpr): + checked_values.append(row) + elif isinstance(row, Expr): + raise TypeError( + "Inflow.value cannot mix PoPS Expr and analytic ScalarExpr values; " + "use pops.analytic.param(...) for parameters" + ) + else: + try: + checked_values.append(analytic_constant(row)) + except (TypeError, ValueError) as exc: + raise TypeError( + "Inflow.value[%d] must be an analytic ScalarExpr or exact scalar" + % index + ) from exc + else: + checked_values = [ + _expression(row, where="Inflow.value[%d]" % index) + for index, row in enumerate(raw_values) + ] object.__setattr__(self, "state", checked_state) - object.__setattr__(self, "values", tuple( - _expression(row, where="Inflow.value[%d]" % index) - for index, row in enumerate(raw_values) - )) + object.__setattr__(self, "values", tuple(checked_values)) object.__setattr__(self, "representation", representation) object.__setattr__(self, "converter", _converter(converter)) @@ -327,16 +423,29 @@ def declaration_references(self) -> tuple[Handle, ...]: converter = () if self.converter is None else (self.converter,) return _unique_references( (self.state,), - *(value.declaration_references() for value in self.values), + *( + value.declaration_references() + if isinstance(value, Expr) + else value.parameter_handles() + for value in self.values + ), converter, ) def resolve_references(self, resolver: Any) -> Inflow: if not callable(resolver): raise TypeError("Inflow.resolve_references requires a callable resolver") - converter = None if self.converter is None else resolver(self.converter) + resolved_state = resolver(self.state) + if self.converter is None: + converter = None + elif self.converter == model_primitive_to_conservative(self.state): + # This provider is derived from the authenticated state, not an independently + # registered declaration. Re-derive its canonical identity after resolving the state. + converter = model_primitive_to_conservative(resolved_state) + else: + converter = resolver(self.converter) return type(self)( - state=resolver(self.state), + state=resolved_state, value=tuple(value.resolve_references(resolver) for value in self.values), representation=self.representation, converter=converter, @@ -424,9 +533,91 @@ def resolve_condition( ) +@dataclass(frozen=True, slots=True, eq=False, init=False) +class SlipWall: + """Model-aware reflective wall: reverse the normal polar-vector component only.""" + + condition_type: ClassVar[str] = "slip_wall" + state: Handle + values: tuple[Expr, ...] + representation: Representation | None + converter: Handle | None + + def __init__(self, *, state: Any) -> None: + object.__setattr__(self, "state", _state(state, where="SlipWall.state")) + object.__setattr__(self, "values", ()) + object.__setattr__(self, "representation", None) + object.__setattr__(self, "converter", None) + + def declaration_references(self) -> tuple[Handle, ...]: + return (self.state,) + + def resolve_references(self, resolver: Any) -> SlipWall: + if not callable(resolver): + raise TypeError("SlipWall.resolve_references requires a callable resolver") + return type(self)(state=resolver(self.state)) + + def inspect(self) -> dict[str, Any]: + return { + "schema_version": _SCHEMA_VERSION, + "condition_type": self.condition_type, + "state": self.state.inspect(), + } + + def resolve_condition( + self, + *, + geometry: DomainBoundary, + boundary: Any, + requirement: BoundaryStencilRequirement, + ) -> ResolvedTransportCondition: + from pops.physics.roles import ComponentRole, native_role_token + + components = _state_components(self.state, where="SlipWall") + space = getattr(self.state, "space", None) + roles = getattr(space, "roles", None) + if not isinstance(roles, Mapping) or set(roles) != set(components): + raise ValueError( + "SlipWall requires one explicit typed physical role for every state component") + tokens = { + component: ( + native_role_token(role) if isinstance(role, ComponentRole) else role) + for component, role in roles.items() + } + supported = { + "AxialX", "AxialY", "AxialZ", "Density", "MomentumX", "MomentumY", + "MomentumZ", "Energy", "VelocityX", "VelocityY", "VelocityZ", "Pressure", + "Temperature", "Scalar", + } + if any(not isinstance(token, str) or token not in supported for token in tokens.values()): + raise ValueError( + "SlipWall requires one explicit typed physical role for every state component") + normal_token = ("MomentumX", "MomentumY", "MomentumZ")[geometry.axis.index] + normal_velocity = ("VelocityX", "VelocityY", "VelocityZ")[geometry.axis.index] + normal = [ + component + for component, token in tokens.items() + if token in {normal_token, normal_velocity} + ] + if not normal: + raise ValueError( + "SlipWall on %s requires a declared normal polar-vector component" + % geometry.name + ) + return _resolved_condition( + self, + condition_type=self.condition_type, + geometry=geometry, + boundary=boundary, + requirement=requirement, + include_state_dependency=True, + ) + + @dataclass(frozen=True, slots=True, eq=False) class ResolvedTransportBoundarySet: domain_geometry_id: str + frame_id: str conditions: tuple[ResolvedTransportCondition, ...] plan: Any @@ -435,18 +626,27 @@ def __post_init__(self) -> None: if not isinstance(self.domain_geometry_id, str) or not self.domain_geometry_id: raise TypeError("resolved transport domain identity must be non-empty text") + if not isinstance(self.frame_id, str) or not self.frame_id: + raise TypeError("resolved transport frame identity must be non-empty text") if not isinstance(self.conditions, tuple) or not self.conditions \ or any(not isinstance(row, ResolvedTransportCondition) for row in self.conditions): raise TypeError("resolved transport conditions must be a non-empty tuple") if not isinstance(self.plan, ResolvedBoundaryPlan): raise TypeError("resolved transport plan must be a ResolvedBoundaryPlan") + # Resolution is the public acceptance boundary for a transport descriptor. Reusing the + # executable contract here prevents a characteristic, representation, analytic, or + # multi-state descriptor from surviving as inert metadata and failing only later during + # compile/bind. compile_boundary_data() and runtime_boundary_data() intentionally call the + # same pure validator again so detached/tampered resolved values remain fail-closed. + self._native_contract() def canonical_identity(self) -> dict[str, Any]: return { "schema_version": _SCHEMA_VERSION, "authority_type": "transport_boundary_set", "domain_geometry_id": self.domain_geometry_id, + "frame_id": self.frame_id, "conditions": [row.canonical_identity() for row in self.conditions], "plan": self.plan.canonical_identity(), } @@ -473,7 +673,12 @@ def compose_ghost_plan(self, context: Any) -> Any: return compose_transport_boundary(self, context=context) def _native_contract(self) -> tuple[Handle, int, tuple[ResolvedTransportCondition, ...], int]: - """Validate the complete compile-time shape of the built-in native provider.""" + """Validate the complete executable shape of the built-in native provider. + + This is the sole acceptance contract used at numerical resolution, compile, and bind. + """ + from pops.mesh.boundaries import ClosureMode + states = {row.state for row in self.conditions} if len(states) != 1: raise NotImplementedError( @@ -485,6 +690,7 @@ def _native_contract(self) -> tuple[Handle, int, tuple[ResolvedTransportConditio raise TypeError("resolved transport boundary state has no component manifest") ncomp = len(components) face_rows: list[ResolvedTransportCondition | None] = [None, None, None, None] + analytic_plan_clocks: set[str] = set() depth = 0 for condition in self.conditions: geometry = condition.geometry @@ -498,30 +704,102 @@ def _native_contract(self) -> tuple[Handle, int, tuple[ResolvedTransportConditio face_rows[face] = condition depth = max(depth, condition.requirement.ghost_depth) dependencies = condition.provider.dependencies - flow = dependencies.representation - if flow.converter is not None or flow.source != flow.target: + if dependencies.characteristic.mode is not ClosureMode.NONE: raise NotImplementedError( - "native transport boundary lowering requires an authored compiled " - "representation converter" + "native transport boundary lowering requires prepared model eigenstructure " + "for characteristic closure; directional modes cannot fall back to " + "component-wise ghost filling" ) + representation, _ = self._native_representation_contract(condition, state) if condition.condition_type == "inflow": - if dependencies.states or dependencies.fields or dependencies.time: - raise NotImplementedError( - "state/field/time-dependent inflow requires a compiled boundary kernel; " - "the built-in native provider accepts only constants and RuntimeParams" - ) if len(condition.values) != ncomp: raise ValueError( "native inflow must prescribe exactly %d state components" % ncomp ) - for expression in condition.values: - if _expression_data( - expression, qualified=True).get("protocol") != "pops.expr.key.v1": - raise NotImplementedError("unsupported boundary expression protocol") + analytic = all(isinstance(row, ScalarExpr) for row in condition.values) + if analytic: + analytic_expressions = tuple( + cast(ScalarExpr, expression) for expression in condition.values + ) + if representation != "conservative": + raise NotImplementedError( + "analytic primitive inflow is unavailable because model conversion " + "must execute per boundary point; author conservative values instead" + ) + if dependencies.states or dependencies.fields: + raise NotImplementedError( + "analytic inflow cannot read discrete state or field storage" + ) + clocks = { + clock.qualified_id + for expression in analytic_expressions + for clock in expression.time_clocks() + } + if len(clocks) > 1: + raise ValueError( + "one analytic inflow face cannot mix several logical Clocks" + ) + analytic_plan_clocks.update(clocks) + for expression in analytic_expressions: + if expression.frame_id not in (None, self.frame_id): + raise ValueError("analytic inflow coordinate belongs to another frame") + if expression.input_references(): + raise NotImplementedError( + "analytic inflow cannot read setup-program discrete inputs" + ) + else: + if any(isinstance(row, ScalarExpr) for row in condition.values): + raise TypeError( + "native inflow values must use one expression protocol per face" + ) + if dependencies.states or dependencies.fields or dependencies.time: + raise NotImplementedError( + "state/field/time-dependent PoPS Expr inflow requires a compiled " + "boundary component" + ) + for expression in condition.values: + if ( + _expression_data(expression, qualified=True).get("protocol") + != "pops.expr.key.v1" + ): + raise NotImplementedError("unsupported boundary expression protocol") if any(row is None for row in face_rows): raise ValueError("native transport boundary has incomplete physical-face coverage") + if len(analytic_plan_clocks) > 1: + raise ValueError("one prepared analytic boundary plan cannot mix several logical Clocks") return state, ncomp, tuple(row for row in face_rows if row is not None), depth + @staticmethod + def _native_representation_contract( + condition: ResolvedTransportCondition, + state: Handle, + ) -> tuple[str, str | None]: + flow = condition.provider.dependencies.representation + target_name = getattr(getattr(state, "space", None), "representation", None) + if target_name != "conservative": + raise NotImplementedError( + "native transport boundaries require a conservative target state") + target = _representation_handle(state, target_name) + if flow.source == target and flow.target == target and flow.converter is None: + return "conservative", None + primitive = _representation_handle(state, "primitive") + expected_converter = model_primitive_to_conservative(state) + if ( + flow.source == primitive + and flow.target == target + and flow.converter == expected_converter + ): + if condition.condition_type != "inflow": + raise NotImplementedError( + "model primitive-to-conservative boundary conversion is defined only for " + "fixed-state inflow data" + ) + return "primitive", expected_converter.qualified_id + raise NotImplementedError( + "native transport boundary representation conversion requires the exact " + "model_primitive_to_conservative(state) provider" + ) + def compile_boundary_data(self) -> dict[str, Any]: """Return deterministic evidence that the authority has a total native lowering. @@ -533,6 +811,7 @@ def compile_boundary_data(self) -> dict[str, Any]: "schema_version": 1, "authority_type": "prepared_boundary_plan_compile", "source_plan": self.plan.canonical_id, + "frame_id": self.frame_id, "state": state.canonical_identity(), "ncomp": ncomp, "required_depth": depth, @@ -543,12 +822,26 @@ def compile_boundary_data(self) -> dict[str, Any]: "condition_type": row.condition_type, "producer": row.provider.qualified_id, "geometry": row.geometry.canonical_identity(), - "type": ("foextrap" if row.condition_type == "outflow" - else "dirichlet"), + "type": { + "outflow": "foextrap", + "inflow": "dirichlet", + "slip_wall": "slip_wall", + }[row.condition_type], + "representation": self._native_representation_contract( + row, state)[0], + "converter": self._native_representation_contract( + row, state)[1], "values": ( - [] if row.condition_type == "outflow" else - [_expression_data(expression, qualified=True)["value"] - for expression in row.values] + [] + if row.condition_type == "outflow" + else [ + ( + _expression_data(expression, qualified=True) + if isinstance(expression, ScalarExpr) + else _expression_data(expression, qualified=True)["value"] + ) + for expression in row.values + ] ), } for row in conditions @@ -559,11 +852,13 @@ def runtime_boundary_data(self, params: Any) -> dict[str, Any]: """Lower this resolved authority to the executable native v1 transport contract. The built-in provider intentionally supports only data that can be executed without a - Python callback: outflow and scalar expressions closed over BindSchema parameters. A - state/field/time-dependent inflow needs a compiled boundary kernel and therefore fails here - instead of being retained as ignored metadata. + Python callback: outflow, scalar expressions closed over BindSchema parameters, and + conservative analytic ``(x, y, t, params)`` inflow programs. Discrete state/field reads + need a compiled boundary component and therefore fail here instead of being retained as + ignored metadata. """ from pops.model._bind_expression import eval_expression_key + from pops.runtime._analytic_expression_lowering import lower_analytic_components if not isinstance(params, Mapping): raise TypeError("runtime boundary lowering requires resolved BindSchema values") @@ -578,29 +873,67 @@ def runtime_boundary_data(self, params: Any) -> dict[str, Any]: for condition in conditions: geometry = condition.geometry face = 2 * geometry.axis.index + (0 if geometry.side.value == "lower" else 1) - if condition.condition_type == "outflow": + if condition.condition_type in {"outflow", "slip_wall"}: values = [0.0] * ncomp - face_type = "foextrap" + face_type = ( + "foextrap" if condition.condition_type == "outflow" else "slip_wall") else: - values = [] - for index, expression in enumerate(condition.values): - data = _expression_data(expression, qualified=True) - value = eval_expression_key( - data["value"], env, - where="transport boundary %s component %d" % (geometry.name, index), + analytic_values = all( + isinstance(expression, ScalarExpr) for expression in condition.values + ) + if analytic_values: + analytic_expressions = tuple( + cast(ScalarExpr, expression) for expression in condition.values ) - if isinstance(value, bool) or not isinstance(value, (int, float)): - raise TypeError( - "transport boundary values must lower to real scalars, got %r" % value + clocks = { + clock.qualified_id + for expression in analytic_expressions + for clock in expression.time_clocks() + } + clock_id = next(iter(clocks), None) + lowered = lower_analytic_components( + [expression.to_data() for expression in analytic_expressions], + frame_id=self.frame_id, + bindings=params, + time_clock_id=clock_id, + ) + analytic_programs = [ + {"opcodes": list(opcodes), "literals": list(literals)} + for opcodes, literals in lowered + ] + values = [0.0] * ncomp + else: + clock_id = None + analytic_programs = [] + values = [] + for index, expression in enumerate(condition.values): + data = _expression_data(expression, qualified=True) + value = eval_expression_key( + data["value"], env, + where="transport boundary %s component %d" % (geometry.name, index), ) - values.append(float(value)) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError( + "transport boundary values must lower to real scalars, got %r" + % value + ) + values.append(float(value)) face_type = "dirichlet" + if condition.condition_type in {"outflow", "slip_wall"}: + analytic_programs = [] + clock_id = None face_rows[face] = { "ordinal": face, "geometry": geometry.canonical_identity(), "producer": condition.provider.qualified_id, "type": face_type, + "representation": self._native_representation_contract( + condition, state)[0], + "converter": self._native_representation_contract( + condition, state)[1], "values": values, + "analytic_programs": analytic_programs, + "analytic_clock": clock_id, } rows = tuple(row for row in face_rows if row is not None) evidence = { @@ -819,6 +1152,7 @@ def labels(rows: Any) -> list[str]: plan = BoundaryProviderRegistry(*providers).resolve(topology, needs) return ResolvedTransportBoundarySet( domain_geometry_id=expected[0].domain_geometry_id, + frame_id=context.frame.canonical_id, conditions=tuple(resolved_conditions), plan=plan, ) @@ -827,8 +1161,10 @@ def labels(rows: Any) -> list[str]: __all__ = [ "BoundaryStencilRequirement", "Inflow", + "model_primitive_to_conservative", "Outflow", "ResolvedTransportBoundarySet", "ResolvedTransportCondition", + "SlipWall", "TransportBoundarySet", ] diff --git a/python/pops/codegen/_compile_emit.py b/python/pops/codegen/_compile_emit.py index 162c811e2..b9ae1f792 100644 --- a/python/pops/codegen/_compile_emit.py +++ b/python/pops/codegen/_compile_emit.py @@ -93,6 +93,11 @@ def _roles_for(names: Any, override: Any = None) -> list: parts.append("prim_state=%s" % ",".join(m.prim_state)) parts.append("proles=%s" % ",".join(_roles_for(m.prim_state, m.prim_roles))) parts.append("prim=%s" % ";".join("%s=%r" % (k, m.prim_defs[k]) for k in m.prim_defs)) + recovery_constraints = getattr(m, "_recovery_admissibility", None) + if recovery_constraints: + parts.append("recovery_admissibility=%s" % ";".join( + "%s=%r" % (name, recovery_constraints[name]) + for name in m.prim_state if name in recovery_constraints)) for d in ("x", "y"): parts.append("flux_%s=%s" % (d, ";".join(repr(e) for e in m._flux.get(d, [])))) parts.append("eig_%s=%s" % (d, ";".join(repr(e) for e in m._eig.get(d, [])))) diff --git a/python/pops/codegen/_compiler_lowering.py b/python/pops/codegen/_compiler_lowering.py index 9d45e150e..885ec68aa 100644 --- a/python/pops/codegen/_compiler_lowering.py +++ b/python/pops/codegen/_compiler_lowering.py @@ -12,6 +12,7 @@ class _CompilerEmitter(Protocol): """Minimal executable half of a compiler lowering.""" def check(self) -> object: ... + def __pops_bind_component_provider_packs__(self, packs: Any) -> None: ... def __pops_native_loader_source__( self, *, name: Any = None, target: str = "system", hoist_reciprocals: bool = False, @@ -26,6 +27,14 @@ class CompilerLowering: source_module: Module facade: object + def bind_component_provider_packs(self, packs: Any) -> None: + """Bind one resolved provider-pack authority before native source emission.""" + result = self.emit_model.__pops_bind_component_provider_packs__(packs) + if result is not None: + raise TypeError( + "compiler provider-pack binding protocol must return None" + ) + def native_loader_source( self, *, name: Any = None, target: str = "system", hoist_reciprocals: bool = False, diff --git a/python/pops/codegen/component_provider_packs.py b/python/pops/codegen/component_provider_packs.py new file mode 100644 index 000000000..0b9664895 --- /dev/null +++ b/python/pops/codegen/component_provider_packs.py @@ -0,0 +1,104 @@ +"""Exact component-provider packs shared by every compiler entry route. + +The operator-first :class:`pops.model.Module` is the authority for provider identity. Kernel +emitters must not rediscover providers from the legacy auxiliary layout: this module resolves the +full pack, every per-operator subset, and the physical-flux subset once and passes that immutable +value through the explicit compiler-emitter protocol. +""" +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any + +from pops.model.provider_pack import ( + ProviderPack, + build_operator_provider_pack, + build_provider_pack, +) + + +@dataclass(frozen=True, slots=True) +class ComponentProviderPacks: + """One immutable provider resolution for a canonical Module.""" + + complete: ProviderPack + by_operator: Mapping[str, ProviderPack] + physical_flux: ProviderPack + + def __post_init__(self) -> None: + if type(self.complete) is not ProviderPack: + raise TypeError("ComponentProviderPacks.complete must be an exact ProviderPack") + rows = dict(self.by_operator) + if any(not isinstance(name, str) or not name for name in rows): + raise TypeError( + "ComponentProviderPacks.by_operator keys must be non-empty strings" + ) + if any(type(pack) is not ProviderPack for pack in rows.values()): + raise TypeError( + "ComponentProviderPacks.by_operator values must be exact ProviderPack values" + ) + object.__setattr__(self, "by_operator", MappingProxyType(rows)) + if type(self.physical_flux) is not ProviderPack: + raise TypeError( + "ComponentProviderPacks.physical_flux must be an exact ProviderPack" + ) + + def attach(self, target: Any) -> None: + """Attach compiler-owned immutable evidence to one emitter carrier. + + Reattachment is idempotent and verifies byte-for-byte logical equality. This is needed + because a facade and its private formula carrier are distinct Python objects but emit one + native package; neither may retain a different provider resolution. + """ + values = { + "_component_provider_pack": self.complete, + "_component_provider_metadata": self.complete.to_data(), + "_component_operator_provider_packs": self.by_operator, + "_component_operator_provider_metadata": MappingProxyType({ + name: pack.to_data() for name, pack in self.by_operator.items() + }), + "_component_flux_provider_pack": self.physical_flux, + "_component_flux_provider_metadata": self.physical_flux.to_data(), + } + + def canonical(value: Any) -> Any: + if isinstance(value, ProviderPack): + return value.to_data() + if isinstance(value, Mapping): + return { + key: canonical(item) + for key, item in value.items() + } + return value + + for name, value in values.items(): + previous = getattr(target, name, None) + if previous is not None and canonical(previous) != canonical(value): + raise ValueError( + "compiler emitter retained a conflicting component-provider pack" + ) + object.__setattr__(target, name, value) + + +def resolve_component_provider_packs(module: Any) -> ComponentProviderPacks: + """Resolve all exact provider packs from one canonical Module authority.""" + complete = build_provider_pack(module) + by_operator = { + operator.name: build_operator_provider_pack(module, operator) + for operator in module.operator_registry() + } + flux_requirements = [] + for operator in module.operator_registry(): + if operator.kind == "grid_operator": + flux_requirements.extend(by_operator[operator.name]) + physical_flux = complete.select(flux_requirements) + return ComponentProviderPacks( + complete=complete, + by_operator=by_operator, + physical_flux=physical_flux, + ) + + +__all__ = ["ComponentProviderPacks", "resolve_component_provider_packs"] diff --git a/python/pops/codegen/module_emit_brick.py b/python/pops/codegen/module_emit_brick.py index 1bdc4c745..7b4b04a0d 100644 --- a/python/pops/codegen/module_emit_brick.py +++ b/python/pops/codegen/module_emit_brick.py @@ -48,6 +48,12 @@ def emit_cpp_brick(model: Any, name: Any = None, namespace: Any = "pops_generate type inside Kokkos kernels; no host-vtable execution path is emitted.""" if not model.prim_state: raise ValueError("emit_cpp_brick : call set_primitive_state(...) first") + if len(model.prim_state) != model.n_vars: + raise ValueError( + "emit_cpp_brick : primitive and conservative states must have equal arity " + "(got %d primitive and %d conservative components)" + % (len(model.prim_state), model.n_vars) + ) if model.cons_from is None or len(model.cons_from) != model.n_vars: raise ValueError("emit_cpp_brick : set_conservative_from([...]) expected (%d expressions)" % model.n_vars) @@ -241,8 +247,10 @@ def roles_init(roles: Any) -> Any: contract["representation"], contract["centering"], contract["unit"] or "", contract["layout"], contract["value_kind"] or "", provider["producer"] or "", ] - S.append(" {%s, %d}," % - (", ".join(json.dumps(value) for value in values), provider["slot"])) + availability = "true" if provider["availability"] else "false" + S.append(" {%s, %s, %d}," % + (", ".join(json.dumps(value) for value in values), + availability, provider["slot"])) S.append(" }};") if rt_member: # member pops::RuntimeParams params{count, {defaults}} (P7-b) S.append(rt_member.rstrip("\n")) @@ -468,6 +476,26 @@ def roles_init(roles: Any) -> Any: S += [" Up[%d] = %s;" % (i, c) for i, c in enumerate(pcpps)] S += [" return Up;", " }", ""] + recovery_constraints = getattr(model, "_recovery_admissibility", {}) + if recovery_constraints: + S.append(" POPS_HD bool recovery_admissible(const Prim& P, int* failing_component_) const {") + S += [" const pops::Real %s = P[%d];" % (name, index) + for index, name in enumerate(model.prim_state)] + for component, name in enumerate(model.prim_state): + predicate = recovery_constraints.get(name) + if predicate is None: + continue + S.append(" if (!(%s)) {" % predicate.to_cpp()) + S.append(" if (failing_component_ != nullptr) *failing_component_ = %d;" % component) + S.append(" return false;") + S.append(" }") + S += [ + " if (failing_component_ != nullptr) *failing_component_ = -1;", + " return true;", + " }", + "", + ] + S.append(" POPS_HD Prim to_primitive(const State& U) const {") S += cons_locals() + prim_locals(_live_prims(model, [], seed=model.prim_state)) S.append(" Prim P{};") diff --git a/python/pops/codegen/module_lowering.py b/python/pops/codegen/module_lowering.py index 56cce3234..d5df9934b 100644 --- a/python/pops/codegen/module_lowering.py +++ b/python/pops/codegen/module_lowering.py @@ -20,7 +20,6 @@ from __future__ import annotations -from types import MappingProxyType from collections.abc import Iterable, Mapping from typing import Any, cast @@ -30,6 +29,36 @@ LoweringRejection, ) +_NATIVE_ROLE_ALIASES = { + "axial_x": "AxialX", + "axial_y": "AxialY", + "axial_z": "AxialZ", + "density": "Density", + "momentum_x": "MomentumX", + "momentum_y": "MomentumY", + "momentum_z": "MomentumZ", + "energy": "Energy", + "pressure": "Pressure", + "velocity_x": "VelocityX", + "velocity_y": "VelocityY", + "velocity_z": "VelocityZ", + "temperature": "Temperature", + "scalar": "Scalar", +} +_NATIVE_ROLE_TOKENS = frozenset(_NATIVE_ROLE_ALIASES.values()) + + +def _lower_native_role(value: Any) -> str | None: + from pops.physics.roles import ComponentRole, native_role_token + + if isinstance(value, ComponentRole): + return native_role_token(value) + if isinstance(value, str): + if value in _NATIVE_ROLE_TOKENS: + return value + return _NATIVE_ROLE_ALIASES.get(value) + return None + def _module_to_model(module: Any, state_space: Any = None) -> Any: """Lower a :class:`pops.model.Module` to a :class:`pops.dsl.Model` @@ -75,30 +104,12 @@ def _body_for_state(body: Any) -> Any: # Preserve the canonical source-Module identity across the internal facade lowering. The # resulting CompiledModel authenticates this scalar hash; it never retains ``module`` itself. object.__setattr__(m, "_compile_source_module_hash", module.module_hash()) - from pops.model.provider_pack import ( # noqa: PLC0415 - build_operator_provider_pack, - build_provider_pack, + from pops.codegen.component_provider_packs import ( # noqa: PLC0415 + resolve_component_provider_packs, ) - provider_pack = build_provider_pack(module) - object.__setattr__(m, "_component_provider_pack", provider_pack) - object.__setattr__(m, "_component_provider_metadata", provider_pack.to_data()) - operator_provider_packs = { - operator.name: build_operator_provider_pack(module, operator) - for operator in module.operator_registry() - } - object.__setattr__(m, "_component_operator_provider_packs", - MappingProxyType(operator_provider_packs)) - object.__setattr__(m, "_component_operator_provider_metadata", MappingProxyType({ - name: pack.to_data() for name, pack in operator_provider_packs.items() - })) - flux_keys = [] - for operator in module.operator_registry(): - if operator.kind == "grid_operator": - flux_keys.extend(operator_provider_packs[operator.name]) - flux_provider_pack = provider_pack.select(flux_keys) - object.__setattr__(m, "_component_flux_provider_pack", flux_provider_pack) - object.__setattr__(m, "_component_flux_provider_metadata", flux_provider_pack.to_data()) + provider_packs = resolve_component_provider_packs(module) + m.__pops_bind_component_provider_packs__(provider_packs) # The facade is a lowering view of THIS Module, not a newly declared model. Re-anchor its empty # backing model before the first declaration so every derived operator registry retains the # Module's exact authoring authority. Without this, owner-qualified Program nodes would be @@ -112,13 +123,9 @@ def _body_for_state(body: Any) -> Any: if registry.owner_path != module.owner_path: raise ValueError("compile_problem: Module ParamRegistry owner drift") object.__setattr__(m, "_param_registry", registry) - _spec_role = {"density": "Density", "momentum_x": "MomentumX", "momentum_y": "MomentumY", - "momentum_z": "MomentumZ", "energy": "Energy", "pressure": "Pressure", - "velocity_x": "VelocityX", "velocity_y": "VelocityY", "velocity_z": "VelocityZ", - "temperature": "Temperature"} roles = None if state.roles: - roles = [_spec_role.get(state.roles.get(c)) for c in state.components] + roles = [_lower_native_role(state.roles.get(c)) for c in state.components] if all(r is None for r in roles): roles = None cvars = m.conservative_vars(*state.components, roles=roles) @@ -191,7 +198,7 @@ def _declare_aux(nm: Any, key: Any) -> None: coverage_rows.append(LoweringCoverageRow( "module:%s:eigenvalues" % module.name, "documentary")) - for key in provider_pack: + for key in provider_packs.complete: key_data = key.to_data() stable_key = "%s/%s/%s" % ( key_data["space_kind"], key_data["space_name"], key_data["component"]) @@ -441,6 +448,11 @@ def lower_and_validate(model: Any, facade: Any = None, state_space: Any = None) lowering = require_compiler_lowering(model) if diagnostic_facade is None: diagnostic_facade = lowering.facade + from pops.codegen.component_provider_packs import resolve_component_provider_packs + + lowering.bind_component_provider_packs( + resolve_component_provider_packs(lowering.source_module) + ) states = lowering.source_module.state_spaces() if len(states) > 1: emit_model = _module_to_model( diff --git a/python/pops/frames/__init__.py b/python/pops/frames/__init__.py index 6e560de9b..df2f35af6 100644 --- a/python/pops/frames/__init__.py +++ b/python/pops/frames/__init__.py @@ -6,8 +6,9 @@ CartesianDirection, X_AXIS, Y_AXIS, + Z_AXIS, ) __all__ = [ - "Cartesian2D", "CartesianAxis", "CartesianDirection", "X_AXIS", "Y_AXIS", + "Cartesian2D", "CartesianAxis", "CartesianDirection", "X_AXIS", "Y_AXIS", "Z_AXIS", ] diff --git a/python/pops/frames/cartesian.py b/python/pops/frames/cartesian.py index 296284102..2fbc14712 100644 --- a/python/pops/frames/cartesian.py +++ b/python/pops/frames/cartesian.py @@ -17,10 +17,15 @@ class CartesianDirection(Enum): - """Closed set of directions carried by :class:`Cartesian2D`.""" + """Closed physical x/y/z component directions. + + :class:`Cartesian2D` carries only x/y as mesh axes; z remains available to type transverse + polar components and out-of-plane axial components in a 2.5D model. + """ X = "x" Y = "y" + Z = "z" @dataclass(frozen=True, slots=True) @@ -35,7 +40,11 @@ def __post_init__(self) -> None: @property def index(self) -> int: - return 0 if self.direction is CartesianDirection.X else 1 + return { + CartesianDirection.X: 0, + CartesianDirection.Y: 1, + CartesianDirection.Z: 2, + }[self.direction] @property def name(self) -> str: @@ -61,7 +70,7 @@ def from_dict(cls, data: Any) -> CartesianAxis: try: result = cls(CartesianDirection(data["direction"])) except (TypeError, ValueError) as exc: - raise ValueError("CartesianAxis direction must be 'x' or 'y'") from exc + raise ValueError("CartesianAxis direction must be 'x', 'y', or 'z'") from exc if result.to_dict() != dict(data): raise ValueError("CartesianAxis data is not canonical") return result @@ -69,6 +78,7 @@ def from_dict(cls, data: Any) -> CartesianAxis: X_AXIS = CartesianAxis(CartesianDirection.X) Y_AXIS = CartesianAxis(CartesianDirection.Y) +Z_AXIS = CartesianAxis(CartesianDirection.Z) @dataclass(frozen=True, slots=True) @@ -127,5 +137,10 @@ def from_dict(cls, data: Any) -> Cartesian2D: __all__ = [ - "Cartesian2D", "CartesianAxis", "CartesianDirection", "X_AXIS", "Y_AXIS", + "Cartesian2D", + "CartesianAxis", + "CartesianDirection", + "X_AXIS", + "Y_AXIS", + "Z_AXIS", ] diff --git a/python/pops/mesh/boundaries/__init__.py b/python/pops/mesh/boundaries/__init__.py index 44321531a..2befc9dac 100644 --- a/python/pops/mesh/boundaries/__init__.py +++ b/python/pops/mesh/boundaries/__init__.py @@ -30,8 +30,9 @@ ConstraintResidual, ExteriorTrace, GhostState, IncomingMultiplicity, NumericalFlux, RepresentationFlow, SignDependence, SonicPolicy) from .providers import ( - BoundaryProvider, BoundaryProviderRegistry, DirectionalTransport, Dirichlet, GhostFormula, - Inflow, Mixed, Neumann, NoFlux, Outflow, ResolvedBoundaryBinding, ResolvedBoundaryPlan) + BoundaryProvider, BoundaryProviderKind, BoundaryProviderRegistry, DirectionalTransport, + Dirichlet, GhostFormula, Inflow, Mixed, Neumann, NoFlux, Outflow, PostRiemannFlux, + ResolvedBoundaryBinding, ResolvedBoundaryPlan) from .topology import ( BoundaryHandle, BoundaryOrientation, BoundarySide, BoundaryTopology, PeriodicIdentification, PeriodicOrientation) @@ -122,8 +123,9 @@ def options(self) -> dict: "PeriodicIdentification", "PeriodicOrientation", "BoundaryDependencies", "BoundaryPort", "CharacteristicClosure", "ClosureMode", "ConstraintResidual", "ExteriorTrace", "GhostState", "IncomingMultiplicity", "NumericalFlux", "RepresentationFlow", "SignDependence", - "SonicPolicy", "BoundaryProvider", "BoundaryProviderRegistry", "DirectionalTransport", - "Dirichlet", "GhostFormula", "Inflow", "Mixed", "Neumann", "NoFlux", "Outflow", + "SonicPolicy", "BoundaryProvider", "BoundaryProviderKind", "BoundaryProviderRegistry", + "DirectionalTransport", "Dirichlet", "GhostFormula", "Inflow", "Mixed", "Neumann", + "NoFlux", "Outflow", "PostRiemannFlux", "ResolvedBoundaryBinding", "ResolvedBoundaryPlan", "BoundaryComponentBinding", "BoundaryLinearizationContribution", "BoundaryResidualContribution", diff --git a/python/pops/mesh/boundaries/compiled_plan.py b/python/pops/mesh/boundaries/compiled_plan.py index 8acd7b69c..a5b2f0f69 100644 --- a/python/pops/mesh/boundaries/compiled_plan.py +++ b/python/pops/mesh/boundaries/compiled_plan.py @@ -173,6 +173,7 @@ def runtime_boundary_data(self, params: Any) -> dict[str, Any]: """Bind scalar values through one generic evaluator, never an authoring callback.""" from pops.model import Handle, ParamHandle from pops.model._bind_expression import eval_expression_key + from pops.runtime._analytic_expression_lowering import lower_analytic_components if not isinstance(params, Mapping): raise TypeError("compiled boundary binding requires resolved BindSchema values") @@ -192,33 +193,87 @@ def runtime_boundary_data(self, params: Any) -> dict[str, Any]: faces = [] for face in data["faces"]: if not isinstance(face, dict) or face.get("type") not in { - "periodic", "foextrap", "dirichlet", "external"}: + "periodic", "foextrap", "dirichlet", "slip_wall", "external"}: raise ValueError("compiled boundary face has no executable producer type") - if face["type"] in {"periodic", "foextrap", "external"}: + representation = face.get("representation", "conservative") + converter = face.get("converter") + if representation not in {"conservative", "primitive"}: + raise ValueError("compiled boundary face has no executable state representation") + if representation == "conservative" and converter is not None: + raise ValueError( + "compiled conservative boundary face must not invent a converter") + if representation == "primitive" and ( + face["type"] != "dirichlet" or not isinstance(converter, str) + or not converter): + raise ValueError( + "compiled primitive boundary face requires one exact fixed-state converter") + if face["type"] in {"periodic", "foextrap", "slip_wall", "external"}: values = [0.0] * ncomp + analytic_programs = [] + analytic_clock = None else: expressions = face.get("values") if not isinstance(expressions, list) or len(expressions) != ncomp: raise ValueError( "compiled Dirichlet boundary must exactly cover every state component" ) - values = [] - for index, expression in enumerate(expressions): - value = eval_expression_key( - expression, - environment, - where="compiled boundary face %d component %d" - % (int(face["ordinal"]), index), + protocols = { + expression.get("protocol") + for expression in expressions + if isinstance(expression, dict) + } + if protocols == {"pops.analytic.scalar.v1"}: + clocks = set() + from pops.analytic import ScalarExpr + + analytic_expressions = [] + for expression in expressions: + analytic = ScalarExpr.from_data(expression["value"]) + analytic_expressions.append(analytic) + clocks.update(clock.qualified_id for clock in analytic.time_clocks()) + if len(clocks) > 1: + raise ValueError("compiled analytic boundary face mixes logical Clocks") + analytic_clock = next(iter(clocks), None) + lowered = lower_analytic_components( + [expression.to_data() for expression in analytic_expressions], + frame_id=data["frame_id"], + bindings=params, + time_clock_id=analytic_clock, + ) + analytic_programs = [ + {"opcodes": list(opcodes), "literals": list(literals)} + for opcodes, literals in lowered + ] + values = [0.0] * ncomp + elif protocols: + raise TypeError( + "compiled Dirichlet boundary mixes unsupported expression protocols" ) - if isinstance(value, bool) or not isinstance(value, (int, float)): - raise TypeError("compiled boundary expression did not bind to a real scalar") - values.append(float(value)) + else: + analytic_programs = [] + analytic_clock = None + values = [] + for index, expression in enumerate(expressions): + value = eval_expression_key( + expression, + environment, + where="compiled boundary face %d component %d" + % (int(face["ordinal"]), index), + ) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError( + "compiled boundary expression did not bind to a real scalar") + values.append(float(value)) faces.append({ "ordinal": int(face["ordinal"]), "geometry": face.get("geometry"), "producer": face.get("producer"), "type": face["type"], + "representation": representation, + "converter": converter, "values": values, + "analytic_programs": analytic_programs, + "analytic_clock": analytic_clock, }) faces.sort(key=lambda row: row["ordinal"]) diff --git a/python/pops/mesh/boundaries/ghost_plan.py b/python/pops/mesh/boundaries/ghost_plan.py index dccb2b06d..433f819bf 100644 --- a/python/pops/mesh/boundaries/ghost_plan.py +++ b/python/pops/mesh/boundaries/ghost_plan.py @@ -10,7 +10,7 @@ from .ghost_plan_types import ( BoundaryLinearizationContribution, BoundaryResidualContribution, CornerPolicy, GhostCoverageManifest, GhostRegion, InterfaceTraceOperation, MultiBlockInterface) -from .providers import BoundaryProvider +from .providers import BoundaryProvider, BoundaryProviderKind from .topology import BoundaryTopology, PeriodicIdentification if TYPE_CHECKING: @@ -133,10 +133,31 @@ def CoarseFineInterpolation(*, handle: Handle, protocol: Handle, interpolation: def PhysicalGhost(*, handle: Handle, protocol: Handle, provider: BoundaryProvider, + flux_provider: BoundaryProvider | None = None, dependencies: tuple[Handle, ...] = ()) -> GhostProducer: if not isinstance(provider, BoundaryProvider): raise TypeError("PhysicalGhost.provider must be a BoundaryProvider") - return GhostProducer(handle, protocol, dependencies, boundary_providers=(provider,)) + providers = (provider,) + if flux_provider is not None: + if not isinstance(flux_provider, BoundaryProvider) or \ + flux_provider.kind is not BoundaryProviderKind.POST_RIEMANN_FLUX: + raise TypeError( + "PhysicalGhost.flux_provider must be a PostRiemannFlux BoundaryProvider") + if any(output.port_type == "numerical_flux" for output in provider.outputs): + raise ValueError( + "PhysicalGhost primary provider must produce the exterior/ghost trace before " + "a post-Riemann flux transformation") + primary_boundaries = {output.boundary for output in provider.outputs} + flux_boundaries = {output.boundary for output in flux_provider.outputs} + primary_subjects = {output.subject for output in provider.outputs} + flux_subjects = {output.subject for output in flux_provider.outputs} + if (len(primary_boundaries) != 1 or flux_boundaries != primary_boundaries or + len(primary_subjects) != 1 or flux_subjects != primary_subjects): + raise ValueError( + "PhysicalGhost trace and post-Riemann providers must own the same exact face and " + "state") + providers += (flux_provider,) + return GhostProducer(handle, protocol, dependencies, boundary_providers=providers) def InterfaceGhost(*, handle: Handle, protocol: Handle, interface: MultiBlockInterface, @@ -581,6 +602,32 @@ def compile_boundary_data(self) -> dict[str, Any]: "explicit corner resolver Handle(s) require qualified GhostBoundary " "components: %s" % sorted(row.qualified_id for row in missing) ) + flux_providers = [ + (production.region, provider) for production in self.productions + for provider in production.producer.boundary_providers + if provider.kind is BoundaryProviderKind.POST_RIEMANN_FLUX + ] + if flux_providers: + invalid = [ + provider for region, provider in flux_providers + if len(provider.outputs) != 1 or provider.outputs[0].subject != region.subject + ] + if invalid: + raise ValueError( + "post-Riemann NumericalFlux provider must transform its production region's " + "exact state: %s" + % sorted(row.qualified_id for row in invalid) + ) + missing = [ + provider.handle for _, provider in flux_providers + if provider.handle not in self._binding_map() + ] + if missing: + raise NotImplementedError( + "post-Riemann NumericalFlux provider Handle(s) require qualified " + "BoundaryFlux components: %s" + % sorted(row.qualified_id for row in missing) + ) closures = [ operator for production in self.productions for operator in production.producer.operators diff --git a/python/pops/mesh/boundaries/providers.py b/python/pops/mesh/boundaries/providers.py index b944c968b..4568d72eb 100644 --- a/python/pops/mesh/boundaries/providers.py +++ b/python/pops/mesh/boundaries/providers.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +from enum import Enum import hashlib import json from typing import TYPE_CHECKING, Any @@ -16,6 +17,36 @@ _SCHEMA_VERSION = 1 +_PROVIDER_SCHEMA_VERSION = 2 + + +class BoundaryProviderKind(Enum): + """Exact immutable law selected by one boundary provider specification.""" + + INFLOW = "inflow" + OUTFLOW = "outflow" + DIRECTIONAL_TRANSPORT = "directional_transport" + MIXED = "mixed" + GHOST_FORMULA = "ghost_formula" + DIRICHLET = "dirichlet" + NEUMANN = "neumann" + NO_FLUX = "no_flux" + POST_RIEMANN_FLUX = "post_riemann_flux" + CONSTRAINT_RESIDUAL = "constraint_residual" + + +_OUTPUT_CONTRACTS: dict[BoundaryProviderKind, type | tuple[type, ...]] = { + BoundaryProviderKind.INFLOW: (ExteriorTrace, GhostState), + BoundaryProviderKind.OUTFLOW: (ExteriorTrace, GhostState), + BoundaryProviderKind.DIRECTIONAL_TRANSPORT: (ExteriorTrace, GhostState), + BoundaryProviderKind.MIXED: ConstraintResidual, + BoundaryProviderKind.GHOST_FORMULA: GhostState, + BoundaryProviderKind.DIRICHLET: ExteriorTrace, + BoundaryProviderKind.NEUMANN: ConstraintResidual, + BoundaryProviderKind.NO_FLUX: NumericalFlux, + BoundaryProviderKind.POST_RIEMANN_FLUX: NumericalFlux, + BoundaryProviderKind.CONSTRAINT_RESIDUAL: ConstraintResidual, +} def _handle(value: Any, *, where: str, kind: str) -> Handle: @@ -79,9 +110,17 @@ class BoundaryProvider: handle: Handle outputs: tuple[BoundaryPort, ...] dependencies: BoundaryDependencies + kind: BoundaryProviderKind def __post_init__(self) -> None: - _handle(self.handle, where="BoundaryProvider.handle", kind="boundary_provider") + if not isinstance(self.kind, BoundaryProviderKind): + raise TypeError("BoundaryProvider.kind must be a BoundaryProviderKind") + handle_kind = ( + "boundary_flux_provider" + if self.kind is BoundaryProviderKind.POST_RIEMANN_FLUX + else "boundary_provider" + ) + _handle(self.handle, where="BoundaryProvider.handle", kind=handle_kind) if not isinstance(self.outputs, tuple) or not self.outputs: raise TypeError("BoundaryProvider.outputs must be a non-empty tuple") if any(not isinstance(row, BoundaryPort) for row in self.outputs): @@ -90,6 +129,25 @@ def __post_init__(self) -> None: raise ValueError("BoundaryProvider contains double output ports") if not isinstance(self.dependencies, BoundaryDependencies): raise TypeError("BoundaryProvider.dependencies must be explicit") + allowed = _OUTPUT_CONTRACTS[self.kind] + if any(not isinstance(row, allowed) for row in self.outputs): + allowed_names = (allowed.__name__ if isinstance(allowed, type) else + "/".join(row.__name__ for row in allowed)) + raise TypeError( + "BoundaryProvider kind %r requires typed %s outputs" + % (self.kind.value, allowed_names) + ) + if self.kind is BoundaryProviderKind.DIRECTIONAL_TRANSPORT and \ + self.dependencies.characteristic.mode is not ClosureMode.DIRECTIONAL: + raise ValueError( + "directional_transport provider requires explicit directional characteristic " + "closure" + ) + if self.kind is not BoundaryProviderKind.DIRECTIONAL_TRANSPORT and \ + self.dependencies.characteristic.mode is ClosureMode.DIRECTIONAL: + raise ValueError( + "directional characteristic closure requires a directional_transport provider" + ) target = self.dependencies.representation.target if any(row.representation != target for row in self.outputs): raise ValueError("provider output representation must match RepresentationFlow.target") @@ -101,7 +159,8 @@ def qualified_id(self) -> str: return self.handle.qualified_id def canonical_identity(self) -> dict[str, Any]: - return {"schema_version": _SCHEMA_VERSION, "provider_type": "boundary", + return {"schema_version": _PROVIDER_SCHEMA_VERSION, "provider_type": "boundary", + "provider_kind": self.kind.value, "handle": self.handle.canonical_identity(), "outputs": [row.canonical_identity() for row in self.outputs], "dependencies": self.dependencies.canonical_identity()} @@ -110,7 +169,7 @@ def inspect(self) -> dict[str, Any]: return {"report_type": "boundary_provider", **self.canonical_identity()} -def _factory(name: str, handle: Any, outputs: Any, dependencies: Any, +def _factory(name: str, kind: BoundaryProviderKind, handle: Any, outputs: Any, dependencies: Any, allowed: type | tuple[type, ...], *, directional: bool = False) -> BoundaryProvider: if not isinstance(outputs, tuple) or not outputs or any( not isinstance(row, allowed) for row in outputs): @@ -121,50 +180,77 @@ def _factory(name: str, handle: Any, outputs: Any, dependencies: Any, raise TypeError("%s dependencies must be BoundaryDependencies" % name) if directional and dependencies.characteristic.mode is not ClosureMode.DIRECTIONAL: raise ValueError("DirectionalTransport requires explicit directional characteristic closure") - return BoundaryProvider(handle, outputs, dependencies) + return BoundaryProvider(handle, outputs, dependencies, kind) def Inflow(*, handle: Any, outputs: tuple[BoundaryPort, ...], dependencies: BoundaryDependencies) -> BoundaryProvider: - return _factory("Inflow", handle, outputs, dependencies, (ExteriorTrace, GhostState)) + return _factory( + "Inflow", BoundaryProviderKind.INFLOW, handle, outputs, dependencies, + (ExteriorTrace, GhostState)) def Outflow(*, handle: Any, outputs: tuple[BoundaryPort, ...], dependencies: BoundaryDependencies) -> BoundaryProvider: - return _factory("Outflow", handle, outputs, dependencies, (ExteriorTrace, GhostState)) + return _factory( + "Outflow", BoundaryProviderKind.OUTFLOW, handle, outputs, dependencies, + (ExteriorTrace, GhostState)) def DirectionalTransport(*, handle: Any, outputs: tuple[BoundaryPort, ...], dependencies: BoundaryDependencies) -> BoundaryProvider: - return _factory("DirectionalTransport", handle, outputs, dependencies, + return _factory("DirectionalTransport", BoundaryProviderKind.DIRECTIONAL_TRANSPORT, + handle, outputs, dependencies, (ExteriorTrace, GhostState), directional=True) def Mixed(*, handle: Any, outputs: tuple[BoundaryPort, ...], dependencies: BoundaryDependencies) -> BoundaryProvider: - return _factory("Mixed", handle, outputs, dependencies, ConstraintResidual) + return _factory( + "Mixed", BoundaryProviderKind.MIXED, handle, outputs, dependencies, ConstraintResidual) def GhostFormula(*, handle: Any, outputs: tuple[BoundaryPort, ...], dependencies: BoundaryDependencies) -> BoundaryProvider: - return _factory("GhostFormula", handle, outputs, dependencies, GhostState) + return _factory( + "GhostFormula", BoundaryProviderKind.GHOST_FORMULA, handle, outputs, dependencies, + GhostState) def Dirichlet(*, handle: Any, outputs: tuple[BoundaryPort, ...], dependencies: BoundaryDependencies) -> BoundaryProvider: - return _factory("Dirichlet", handle, outputs, dependencies, ExteriorTrace) + return _factory( + "Dirichlet", BoundaryProviderKind.DIRICHLET, handle, outputs, dependencies, + ExteriorTrace) def Neumann(*, handle: Any, outputs: tuple[BoundaryPort, ...], dependencies: BoundaryDependencies) -> BoundaryProvider: - return _factory("Neumann", handle, outputs, dependencies, ConstraintResidual) + return _factory( + "Neumann", BoundaryProviderKind.NEUMANN, handle, outputs, dependencies, + ConstraintResidual) def NoFlux(*, handle: Any, output: NumericalFlux, dependencies: BoundaryDependencies) -> BoundaryProvider: if not isinstance(output, NumericalFlux): raise TypeError("NoFlux satisfies NumericalFlux only") - return BoundaryProvider(handle, (output,), dependencies) + return BoundaryProvider(handle, (output,), dependencies, BoundaryProviderKind.NO_FLUX) + + +def PostRiemannFlux(*, handle: Any, output: NumericalFlux, + dependencies: BoundaryDependencies) -> BoundaryProvider: + """Bind one exact native transformation of an already evaluated outward flux. + + The provider owns neither reconstruction nor the Riemann solve. Its + ``boundary_flux_provider`` Handle resolves only to the typed + ``BoundaryFlux.transform_faces`` ABI, so it cannot be mistaken for a ghost + producer or for the shared-interface ``NumericalFlux.evaluate_faces`` route. + """ + if not isinstance(output, NumericalFlux): + raise TypeError("PostRiemannFlux satisfies NumericalFlux only") + return BoundaryProvider( + handle, (output,), dependencies, BoundaryProviderKind.POST_RIEMANN_FLUX) @dataclass(frozen=True, slots=True) @@ -273,7 +359,8 @@ def resolve(self, topology: Any, needs: Any) -> ResolvedBoundaryPlan: __all__ = [ - "BoundaryProvider", "BoundaryProviderRegistry", "DirectionalTransport", "Dirichlet", - "GhostFormula", "Inflow", "Mixed", "Neumann", "NoFlux", "Outflow", + "BoundaryProvider", "BoundaryProviderKind", "BoundaryProviderRegistry", "DirectionalTransport", + "Dirichlet", + "GhostFormula", "Inflow", "Mixed", "Neumann", "NoFlux", "Outflow", "PostRiemannFlux", "ResolvedBoundaryBinding", "ResolvedBoundaryPlan", ] diff --git a/python/pops/model/_generated_component_schema.py b/python/pops/model/_generated_component_schema.py index 0b9390e80..92f7f57da 100644 --- a/python/pops/model/_generated_component_schema.py +++ b/python/pops/model/_generated_component_schema.py @@ -3,8 +3,8 @@ COMPONENT_CATALOG_SCHEMA_VERSION = 1 COMPONENT_MANIFEST_SCHEMA_VERSION = 2 -COMPONENT_CATALOG_SHA256 = '5c67c081cf1808138583ed00856e6601c12384ae28e9c0f8cc7b8ce004c3b0f6' -COMPONENT_CATALOG_SEMANTIC_SHA256 = 'adbb3693dc17eff5aa7b78415df35f011dfd2c64fc26eb9a98200923e52c47ea' +COMPONENT_CATALOG_SHA256 = 'a10653b4730d0e5a8d8b1c21d3bb4263f3ca8fc93ebdfb1af59b88c7cbce07f0' +COMPONENT_CATALOG_SEMANTIC_SHA256 = '9d7d38624833a7a7d7462ac7113bbdc215e6bce145ad1150b8cc1decf2d297b1' COMPONENT_INTERFACE_SPECS = ({'name': 'requirement', 'method': 'requirements', 'required_args': 0}, {'name': 'lowering', 'method': 'lower', 'required_args': 1}, {'name': 'stencil', 'method': 'stencil', 'required_args': 0}, diff --git a/python/pops/model/provider_pack.py b/python/pops/model/provider_pack.py index 16d42b251..fa2a8ba50 100644 --- a/python/pops/model/provider_pack.py +++ b/python/pops/model/provider_pack.py @@ -256,6 +256,50 @@ def select_spaces(self, *, owner_qid: str, (owner_qid, sorted(missing))) return self.select(keys) + def select_components( + self, + *, + owner_qid: str, + spaces: Iterable[tuple[str, str]], + components: Iterable[str], + ) -> ProviderPack: + """Select exact components from declared spaces without a bare-name fallback. + + Component spelling is only a filter inside the already-qualified owner/space set. A + missing component or the same spelling in two selected spaces is rejected rather than + guessed, so an operator that needs one of two homonymous fields must qualify its input + space more narrowly. + """ + _non_empty(owner_qid, "ProviderPack selection owner_qid") + requested_spaces = set(spaces) + requested_components = tuple(components) + if any(not isinstance(name, str) or not name for name in requested_components): + raise TypeError( + "ProviderPack components must contain non-empty strings" + ) + if len(set(requested_components)) != len(requested_components): + raise ValueError("ProviderPack components contains a duplicate") + candidates = [ + key for key in self + if key.owner_qid == owner_qid + and (key.space_kind, key.space_name) in requested_spaces + ] + selected = [] + for component in requested_components: + matches = [key for key in candidates if key.component == component] + if not matches: + raise MissingInputProvider( + "missing component %r in qualified provider spaces %r for owner %r" + % (component, sorted(requested_spaces), owner_qid) + ) + if len(matches) != 1: + raise MissingInputProvider( + "ambiguous component %r in qualified provider spaces %r for owner %r" + % (component, sorted(requested_spaces), owner_qid) + ) + selected.append(matches[0]) + return self.select(selected) + def to_data(self) -> dict[str, Any]: rows = [] for key in sorted(self._entries): @@ -363,7 +407,16 @@ def build_operator_provider_pack(module: Any, operator: Any) -> ProviderPack: spaces.append(("field", input_space.name)) if not spaces: return ProviderPack(capacity=full.capacity) - return full.select_spaces(owner_qid=str(module.owner_path.canonical()), spaces=spaces) + owner_qid = str(module.owner_path.canonical()) + requirements = getattr(operator, "requirements", {}) + required_components = requirements.get("aux", ()) + if required_components: + return full.select_components( + owner_qid=owner_qid, + spaces=spaces, + components=required_components, + ) + return full.select_spaces(owner_qid=owner_qid, spaces=spaces) __all__ = ["ComponentKey", "ComponentContract", "ProviderEntry", "ProviderPack", diff --git a/python/pops/numerics/reconstruction/__init__.py b/python/pops/numerics/reconstruction/__init__.py index 9f2c881d1..62f68b02e 100644 --- a/python/pops/numerics/reconstruction/__init__.py +++ b/python/pops/numerics/reconstruction/__init__.py @@ -36,6 +36,8 @@ "none": 1, "minmod": 2, "vanleer": 2, + "mc": 2, + "superbee": 2, "weno5": 3, }) @@ -48,7 +50,7 @@ #: halo a caller passes), never against this assumption -- rejecting WENO5 by default would be a #: FALSE POSITIVE that breaks a working problem. INSPECT_GHOST_DEPTH_ASSUMPTION = max( - REQUIRED_GHOST_DEPTH[token] for token in ("minmod", "vanleer") + REQUIRED_GHOST_DEPTH[token] for token in ("minmod", "vanleer", "mc", "superbee") ) @@ -156,7 +158,8 @@ def _muscl(limiter: Any = None) -> Any: selected = Minmod() if limiter is None else limiter if isinstance(selected, str) or getattr(selected, "category", None) != "limiter": raise TypeError( - "MUSCL(limiter=) requires a typed limiter descriptor such as Minmod() or VanLeer()" + "MUSCL(limiter=) requires a typed limiter descriptor such as Minmod(), VanLeer(), " + "MC(), or Superbee()" ) route = authenticated_reconstruction_route(selected, require_muscl_limiter=True) return _native_reconstruction_descriptor( @@ -205,7 +208,8 @@ def required_ghost_depth(reconstruction_or_token: Any) -> Any: """The ghost depth a reconstruction NEEDS (Spec 5 sec.7 / criterion 11). Accepts an authenticated native reconstruction descriptor or a canonical lowered scheme token - (``"none"`` / ``"minmod"`` / ``"vanleer"`` / ``"weno5"``). Returns ``None`` when the + (``"none"`` / ``"minmod"`` / ``"vanleer"`` / ``"mc"`` / ``"superbee"`` / + ``"weno5"``). Returns ``None`` when the requirement is not declared/known -- the caller then does NOT reject (a missing requirement is not a known incompatibility; no false positive). """ diff --git a/python/pops/numerics/reconstruction/limiters.py b/python/pops/numerics/reconstruction/limiters.py index 8ff9f4e96..89b25135e 100644 --- a/python/pops/numerics/reconstruction/limiters.py +++ b/python/pops/numerics/reconstruction/limiters.py @@ -55,8 +55,20 @@ def VanLeer() -> Any: return _native_reconstruction_descriptor(LIMITER_VANLEER, category="limiter") -limiters = SimpleNamespace(Minmod=Minmod, VanLeer=VanLeer) +def MC() -> Any: + from pops.runtime.routes import LIMITER_MC + + return _native_reconstruction_descriptor(LIMITER_MC, category="limiter") + + +def Superbee() -> Any: + from pops.runtime.routes import LIMITER_SUPERBEE + + return _native_reconstruction_descriptor(LIMITER_SUPERBEE, category="limiter") + + +limiters = SimpleNamespace(Minmod=Minmod, VanLeer=VanLeer, MC=MC, Superbee=Superbee) # Spec 5: expose the limiters at module scope. -__all__ = ["limiters", "Minmod", "VanLeer"] +__all__ = ["limiters", "Minmod", "VanLeer", "MC", "Superbee"] diff --git a/python/pops/physics/__init__.py b/python/pops/physics/__init__.py index 829ef6dbd..3ff2cb0da 100644 --- a/python/pops/physics/__init__.py +++ b/python/pops/physics/__init__.py @@ -7,6 +7,7 @@ from .board import Model from .roles import ( + Axial, ComponentRole, Density, Energy, @@ -18,6 +19,6 @@ ) __all__ = [ - "Model", "ComponentRole", "Density", "Energy", "Momentum", "Pressure", "Scalar", - "Temperature", "Velocity", + "Model", "Axial", "ComponentRole", "Density", "Energy", "Momentum", "Pressure", + "Scalar", "Temperature", "Velocity", ] diff --git a/python/pops/physics/_authoring_recovery.py b/python/pops/physics/_authoring_recovery.py new file mode 100644 index 000000000..c517863e9 --- /dev/null +++ b/python/pops/physics/_authoring_recovery.py @@ -0,0 +1,64 @@ +"""Primitive-recovery policy authoring for symbolic physical models.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from pops._ir import _wrap + +if TYPE_CHECKING: + from ._model_contract import _HyperbolicModel +else: + _HyperbolicModel = object + + +class _RecoveryMixin(_HyperbolicModel): + """Declare physical constraints consumed by native variable recovery.""" + + def recovery_admissibility(self, **constraints: Any) -> None: + """Require named primitive components to satisfy symbolic predicates. + + Keys identify components of the already-declared primitive state. Values are typed + symbolic Boolean expressions over that primitive state, for example + ``rho=rho > 0`` or ``p=p >= p_floor``. The generated C++ brick reports the first failing + primitive component, and the prepared recovery chain refuses to publish that candidate. + """ + if not self.prim_state: + raise ValueError( + "recovery_admissibility: call primitive_vars(...) first so constraints have " + "a typed component layout" + ) + if not constraints: + raise ValueError("recovery_admissibility: declare at least one named constraint") + if self._recovery_admissibility: + raise ValueError( + "recovery_admissibility: policy already declared; author the complete policy " + "in one call" + ) + + primitive_names = set(self.prim_state) + unknown_components = sorted(set(constraints) - primitive_names) + if unknown_components: + raise ValueError( + "recovery_admissibility: unknown primitive components %s; declared layout is %s" + % (unknown_components, list(self.prim_state)) + ) + + prepared = {} + for component in self.prim_state: + if component not in constraints: + continue + predicate = _wrap(constraints[component]) + if not callable(getattr(predicate, "resolve_for_amr_predicate", None)): + raise TypeError( + "recovery_admissibility[%r] requires a typed symbolic Boolean expression" + % component + ) + unknown_dependencies = sorted(set(predicate.deps()) - primitive_names) + if unknown_dependencies: + raise ValueError( + "recovery_admissibility[%r] reads values outside the primitive state: %s" + % (component, unknown_dependencies) + ) + prepared[component] = predicate + + self._recovery_admissibility = prepared diff --git a/python/pops/physics/_authoring_view.py b/python/pops/physics/_authoring_view.py index 173ce2ae3..9a4a267b9 100644 --- a/python/pops/physics/_authoring_view.py +++ b/python/pops/physics/_authoring_view.py @@ -30,7 +30,23 @@ def _aux_name_set(self) -> Any: def _aux_requirements(self, exprs: Any) -> Any: """{'aux': [...]} of the aux fields the expressions read, or {} if none.""" aux_set = self._aux_name_set() - read = sorted(_dependencies(exprs) & aux_set) + dependencies = _dependencies(exprs) + pending = [name for name in dependencies if name in self.prim_defs] + expanded = set(dependencies) + visited = set() + while pending: + name = pending.pop() + if name in visited: + continue + visited.add(name) + nested = _dependencies((self.prim_defs[name],)) + expanded.update(nested) + pending.extend( + dependency + for dependency in nested + if dependency in self.prim_defs and dependency not in visited + ) + read = sorted(expanded & aux_set) return {"aux": read} if read else {} def state_space(self, name: str = "U") -> Any: @@ -85,25 +101,68 @@ def operator_registry(self, state_name: str = "U") -> Any: reg = _model.OperatorRegistry(owner=self.owner_path) state = self.state_space(state_name) fields = self.field_space() - aux_set = self._aux_name_set() def reads_fields(exprs: Any) -> bool: - return bool(_dependencies(exprs) & aux_set) + return bool(self._aux_requirements(exprs)) + + stability_exprs = [ + *self._eig.get("x", ()), + *self._eig.get("y", ()), + ] + if self._wave_speeds is not None: + stability_exprs.extend(self._wave_speeds["x"]) + stability_exprs.extend(self._wave_speeds["y"]) + if self._ws_jacobian is not None and self._ws_jacobian["rows"] is not None: + for direction in ("x", "y"): + stability_exprs.extend( + expression + for row in self._ws_jacobian["rows"][direction] + for expression in row + ) + if self._roe_rows is not None: + stability_exprs.extend(self._roe_rows["x"]) + stability_exprs.extend(self._roe_rows["y"]) + if self._roe_jacobian is not None: + for direction in ("x", "y"): + stability_exprs.extend( + expression + for row in self._roe_jacobian[direction] + for expression in row + ) # Flux divergence (grid_operator: State -> Rate(State)). if self._flux: + exprs = [ + *self._flux.get("x", ()), + *self._flux.get("y", ()), + *stability_exprs, + ] + rf = reads_fields(exprs) reg.register(_model.Operator( "flux_default", "grid_operator", - _model.Signature([state], _model.Rate(state)), + _model.Signature([state, fields] if rf else [state], + _model.Rate(state)), capabilities={"local": False, "linear": False, "produces_rate": True, "requires_ghosts": 1, "supports_device": True, - "default": True}, + "requires_fields": rf, "default": True}, + requirements=self._aux_requirements(exprs), source=None)) for nm in sorted(self._flux_terms): + term = self._flux_terms[nm] + exprs = [ + *term.get("x", ()), + *term.get("y", ()), + *stability_exprs, + ] + rf = reads_fields(exprs) reg.register(_model.Operator( - nm, "grid_operator", _model.Signature([state], _model.Rate(state)), + nm, "grid_operator", + _model.Signature([state, fields] if rf else [state], + _model.Rate(state)), capabilities={"local": False, "linear": False, "produces_rate": True, - "requires_ghosts": 1, "supports_device": True}, + "requires_ghosts": 1, "supports_device": True, + "requires_fields": rf}, + requirements=self._aux_requirements(exprs), source=None)) # Local sources (local_source: State[, Fields] -> Rate(State)). diff --git a/python/pops/physics/_coupled_abi.py b/python/pops/physics/_coupled_abi.py index bf9f38489..ae54ef485 100644 --- a/python/pops/physics/_coupled_abi.py +++ b/python/pops/physics/_coupled_abi.py @@ -5,6 +5,9 @@ ROLE_TO_CANONICAL = { + "AxialX": "axial_x", + "AxialY": "axial_y", + "AxialZ": "axial_z", "Density": "density", "MomentumX": "momentum_x", "MomentumY": "momentum_y", diff --git a/python/pops/physics/_facade.py b/python/pops/physics/_facade.py index a2ca573e2..6117355eb 100644 --- a/python/pops/physics/_facade.py +++ b/python/pops/physics/_facade.py @@ -24,7 +24,8 @@ class Model(PhysicsFreezable, _FacadeCompileMixin): """ _physics_mutators = frozenset({ - "conservative_vars", "primitive", "primitive_vars", "aux", "aux_field", + "conservative_vars", "primitive", "primitive_vars", "recovery_admissibility", "aux", + "aux_field", "conservative_from", "flux", "flux_term", "eigenvalues", "wave_speeds", "wave_speeds_from_jacobian", "stability_speed", "stability_dt", "source", "source_term", "linear_source", "rate_operator", "rate", "field_solve", @@ -94,6 +95,16 @@ def primitive_vars(self, *vars: Any, roles: Any = None, **named: Any) -> Any: self._m.set_primitive_state(*vars, roles=roles) return None + def recovery_admissibility(self, **constraints: Any) -> None: + """Declare fail-closed physical constraints for primitive recovery candidates. + + Each keyword names one component of the primitive layout and maps it to a symbolic Boolean + expression over primitive variables. Example: ``rho=rho > 0, p=p > 0``. Finite candidates + that violate a declared predicate are rejected by the native prepared recovery chain before + any solution or warm-start publication. + """ + self._m.recovery_admissibility(**constraints) + def aux(self, name: Any) -> Any: """CANONICAL auxiliary field (must be a key of AUX_CANONICAL: phi/grad_x/grad_y/B_z/T_e).""" return self._m.aux(name) diff --git a/python/pops/physics/_facade_compile.py b/python/pops/physics/_facade_compile.py index 746de5ad1..f1ef03f44 100644 --- a/python/pops/physics/_facade_compile.py +++ b/python/pops/physics/_facade_compile.py @@ -40,11 +40,27 @@ def __pops_compiler_lowering__(self) -> Any: facade=self, ) + def __pops_bind_component_provider_packs__(self, packs: Any) -> None: + """Bind the exact Module provider resolution to both native-emitter carriers.""" + from pops.codegen.component_provider_packs import ComponentProviderPacks + + if type(packs) is not ComponentProviderPacks: + raise TypeError( + "compiler provider-pack binding requires exact ComponentProviderPacks" + ) + packs.attach(self) + packs.attach(self._m) + def __pops_native_loader_source__( self, *, name: Any = None, target: str = "system", hoist_reciprocals: bool = False, ) -> str: """Emit a native package without exposing the private formula carrier.""" + from pops.codegen.component_provider_packs import resolve_component_provider_packs + + self.__pops_bind_component_provider_packs__( + resolve_component_provider_packs(self.module) + ) return self._m.emit_cpp_native_loader( name=name, target=target, hoist_reciprocals=hoist_reciprocals) diff --git a/python/pops/physics/_model.py b/python/pops/physics/_model.py index 0669ef5ab..2388d8b1f 100644 --- a/python/pops/physics/_model.py +++ b/python/pops/physics/_model.py @@ -22,6 +22,7 @@ from pops.model.ownership import OwnerKind, OwnerPath from ._authoring_vars import _VariablesMixin +from ._authoring_recovery import _RecoveryMixin from ._authoring_flux import _FluxMixin from ._authoring_sources import _SourceMixin from ._authoring_riemann import _RiemannMixin @@ -32,7 +33,8 @@ from ._freeze import PhysicsFreezable -class HyperbolicModel(PhysicsFreezable, _VariablesMixin, _FluxMixin, _SourceMixin, _RiemannMixin, +class HyperbolicModel(PhysicsFreezable, _VariablesMixin, _RecoveryMixin, _FluxMixin, _SourceMixin, + _RiemannMixin, _OperatorViewMixin, _EvalMixin, _RuntimeParamsMixin, _CodegenMixin): """Hyperbolic model written as FORMULAS: conservative variables, primitives (defined by expressions), flux, eigenvalues, source, elliptic contribution. cf. module docstring. @@ -42,6 +44,7 @@ class HyperbolicModel(PhysicsFreezable, _VariablesMixin, _FluxMixin, _SourceMixi _physics_mutators = frozenset({ "cons", "conservative_vars", "primitive", "aux", "aux_field", + "recovery_admissibility", "set_primitive_state", "set_conservative_from", "set_flux", "set_eigenvalues", "flux_term", "set_wave_speeds", "set_wave_speeds_from_jacobian", "set_gamma", "set_source", "set_elliptic_rhs", "elliptic_field", "source_term", "linear_source", @@ -111,6 +114,7 @@ def __init__(self, name: Any) -> None: } self.cons_names = [] self.prim_defs = {} # name -> Expr (in terms of the cons / previous prims / aux) + self._recovery_admissibility = {} # primitive component -> symbolic Boolean predicate self.aux_names = [] # CANONICAL aux fields read (phi/grad/B_z/T_e), cf. AUX_CANONICAL self.aux_extra_names = [] # NAMED aux fields (aux_field): order = index AUX_NAMED_BASE + k self._flux = {} # "x" / "y" -> list of Expr (one per conservative component) diff --git a/python/pops/physics/_model_contract.py b/python/pops/physics/_model_contract.py index 3ae804449..44700acb2 100644 --- a/python/pops/physics/_model_contract.py +++ b/python/pops/physics/_model_contract.py @@ -32,6 +32,7 @@ class _HyperbolicModel: prim_defs: Any prim_roles: Any prim_state: Any + _recovery_admissibility: Any aux_names: Any aux_extra_names: Any gamma: Any diff --git a/python/pops/physics/board.py b/python/pops/physics/board.py index 7246baf8d..4ec675024 100644 --- a/python/pops/physics/board.py +++ b/python/pops/physics/board.py @@ -60,7 +60,7 @@ class Model(PhysicsFreezable, _BoardCompileMixin, _RateAuthoringMixin, _RiemannA "operator", "riemann", "invariant", "rate", "finite_volume_rate", "coupled_rate", "field_provider", "local_transform", "projection", "wave_speeds", "wave_speeds_from_jacobian", - "roe_from_jacobian", + "roe_from_jacobian", "recovery_admissibility", }) def __init__(self, name: Any, *, frame: Any = None) -> None: @@ -515,6 +515,24 @@ def primitive_state( self._dsl._invalidate_authoring_views() self._invalidate_authoring_views() + def recovery_admissibility(self, **constraints: Any) -> None: + """Declare physical constraints for native primitive-recovery candidates. + + Each keyword names a component of the model's primitive coordinate system and maps it to a + symbolic Boolean expression over that coordinate system. The single-state native route + compiles these predicates into the prepared recovery plan; multi-state recovery policies + require a species-qualified provider and are therefore rejected here. + """ + if self._multi_module is not None: + raise ValueError( + "recovery_admissibility requires a single-state model; multi-species policies " + "must be supplied by a species-qualified recovery provider" + ) + self._dsl.recovery_admissibility( + **{name: self._to_expr(predicate) for name, predicate in constraints.items()} + ) + self._invalidate_authoring_views() + def scalar(self, name: Any, expr: Any) -> Any: """Define a named derived scalar (e.g. pressure, sound speed).""" value = self._dsl.primitive(require_name(name, "scalar name"), expr) diff --git a/python/pops/physics/roles.py b/python/pops/physics/roles.py index e11494631..e0543bf4e 100644 --- a/python/pops/physics/roles.py +++ b/python/pops/physics/roles.py @@ -9,8 +9,9 @@ _ROLE_TOKEN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _RESERVED_ROLE_TOKENS = frozenset({"Custom"}) _CANONICAL_ROLE_TOKENS = frozenset({ - "Density", "Energy", "MomentumX", "MomentumY", "MomentumZ", "Pressure", "Scalar", - "Temperature", "VelocityX", "VelocityY", "VelocityZ", + "AxialX", "AxialY", "AxialZ", "Density", "Energy", "MomentumX", "MomentumY", + "MomentumZ", "Pressure", "Scalar", "Temperature", "VelocityX", "VelocityY", + "VelocityZ", }) @@ -85,6 +86,22 @@ def native_name(self) -> str: return "Velocity" + str(self.axis.name).upper() +@dataclass(frozen=True, slots=True) +class Axial(ComponentRole): + """One component of an axial (pseudo-)vector under reflection.""" + + axis: Any + + def __post_init__(self) -> None: + name = getattr(self.axis, "name", None) + if name not in ("x", "y", "z"): + raise TypeError("Axial axis must be a typed Cartesian x/y/z axis") + + @property + def native_name(self) -> str: + return "Axial" + str(self.axis.name).upper() + + @dataclass(frozen=True, slots=True) class Pressure(ComponentRole): @property @@ -107,6 +124,6 @@ def native_name(self) -> str: __all__ = [ - "ComponentRole", "Density", "Energy", "Momentum", "Pressure", "Scalar", + "Axial", "ComponentRole", "Density", "Energy", "Momentum", "Pressure", "Scalar", "Temperature", "Velocity", "native_role_token", ] diff --git a/python/pops/runtime/_amr_checkpoint_contract.py b/python/pops/runtime/_amr_checkpoint_contract.py index 152a0fa0a..a60cbbefc 100644 --- a/python/pops/runtime/_amr_checkpoint_contract.py +++ b/python/pops/runtime/_amr_checkpoint_contract.py @@ -8,7 +8,7 @@ from pops.identity import make_identity -_SCHEMA = 4 +_SCHEMA = 5 _GUARANTEE = "bit_identical_accepted_state" _CONTRACT_KEYS = { "schema_version", @@ -17,6 +17,7 @@ "ledger", "interface_ledger", "clocks", + "temporal_partition", "synchronization", "history_qualifications", "level_relations", @@ -39,9 +40,7 @@ def restart_topology_image(sim): """Return the compact identity of one accepted AMR hierarchy.""" levels = int(sim.n_levels()) boxes = [[int(value) for value in box] for box in sim.patch_boxes()] - owners = [ - [int(rank) for rank in sim.level_owner_ranks(level)] for level in range(levels) - ] + owners = [[int(rank) for rank in sim.level_owner_ranks(level)] for level in range(levels)] topology_identity = make_identity( "restart-topology", { @@ -94,6 +93,7 @@ def contract_for(sim): "entries": interface_flux_ledger, }, "clocks": _rows(sim.program_clock_manifest()), + "temporal_partition": _rows(sim.program_temporal_partition_manifest()), "synchronization": _rows(sim.program_sync_manifest()), "history_qualifications": _rows(sim.program_accepted_state_manifest()), "level_relations": relations, @@ -116,6 +116,25 @@ def _decode_contract(payload): return contract +def checkpoint_temporal_partition_kind(payload): + """Return the exact accepted temporal-partition kind before native restart mutation.""" + contract = _decode_contract(payload) + rows = contract["temporal_partition"] + if ( + not isinstance(rows, list) + or not rows + or not isinstance(rows[0], list) + or len(rows[0]) != 7 + or rows[0][0] != "summary" + or rows[0][1] not in {"global", "cell_local"} + ): + raise ValueError("restart: AMR temporal-partition contract has an invalid summary") + for row in rows[1:]: + if not isinstance(row, list) or len(row) != 3 or row[0] != "rung": + raise ValueError("restart: AMR temporal-partition contract has an invalid rung row") + return rows[0][1] + + def preflight_contract(sim, payload): """Authenticate shape and static provenance before the native restart transaction.""" import numpy as np @@ -159,9 +178,7 @@ def _validate_interface_ledger_against_live_hierarchy(sim, contract): blocks = int(sim.n_blocks()) for row in contract["interface_ledger"]["entries"]: if len(row) != 28: - raise ValueError( - "restart: restored AMR interface-flux audit has an invalid native row" - ) + raise ValueError("restart: restored AMR interface-flux audit has an invalid native row") coarse_level, fine_level = int(row[2]), int(row[3]) left_block, right_block = int(row[21]), int(row[22]) if ( diff --git a/python/pops/runtime/_amr_checkpoint_v3.py b/python/pops/runtime/_amr_checkpoint_v3.py index b83678cab..8969f0d8f 100644 --- a/python/pops/runtime/_amr_checkpoint_v3.py +++ b/python/pops/runtime/_amr_checkpoint_v3.py @@ -345,9 +345,7 @@ def _capture_v3(owner, sim, prepared): if prepared.local_program_state: rematerialize = getattr(sim, "rematerialize_program_accepted_state", None) if not callable(rematerialize): - raise TypeError( - "checkpoint AMR engine lacks accepted-state consensus validation" - ) + raise TypeError("checkpoint AMR engine lacks accepted-state consensus validation") # Re-materializing onto the unchanged ownership is a non-mutating validation pass. It # authenticates every rank-independent accepted field, including persistent tagging, # before any rank may seal or publish a checkpoint. @@ -447,7 +445,10 @@ def prepare_v3( """ import numpy as np from pops.output._checkpoint_collective import checkpoint_topology - from pops.runtime._amr_checkpoint_contract import preflight_contract + from pops.runtime._amr_checkpoint_contract import ( + checkpoint_temporal_partition_kind, + preflight_contract, + ) from pops.runtime._program_cadence_checkpoint import prepare_program_cadence from pops.runtime._temporal_restart import TemporalRestartState @@ -488,6 +489,17 @@ def prepare_v3( raise ValueError( "restart: RegridOnRestart requires an artifact-backed compiled AMR Program" ) + if checkpoint_temporal_partition_kind(d) == "cell_local": + if checkpoint_ranks != current_ranks: + raise ValueError( + "restart: cell-local temporal partitions require the recorded MPI cardinality " + "until ownership rematerialization is implemented" + ) + if hierarchy_mode != "restore_recorded_hierarchy": + raise ValueError( + "restart: cell-local temporal partitions require RestoreRecordedHierarchy " + "until regrid rematerialization is implemented" + ) checkpoint_levels, checkpoint_configured_levels = _checkpoint_amr_level_envelope(sim, d) from pops.runtime._amr_checkpoint_topology import recorded_rank_topology @@ -1060,9 +1072,7 @@ def apply_v3(owner, sim, prepared): "recorded accepted-contract identity", lambda: _restart_accepted_contract_identity(sim), ) - before_history_identity = _restart_history_identity( - owner, sim, phase="recorded hierarchy" - ) + before_history_identity = _restart_history_identity(owner, sim, phase="recorded hierarchy") before_integrals = _restart_composite_integrals(owner, sim, phase="recorded hierarchy") _restart_collective_phase( owner, diff --git a/python/pops/runtime/_amr_system.py b/python/pops/runtime/_amr_system.py index 1e4c3319f..16416f356 100644 --- a/python/pops/runtime/_amr_system.py +++ b/python/pops/runtime/_amr_system.py @@ -312,7 +312,8 @@ def add_block(self, name: Any, model: Any, spatial: Any = None, time: Any = None @param model private ``ModelSpec`` engine value composed from native bricks. @param spatial private engine adapter lowered from ``pops.numerics.FiniteVolume(...)`` (default minmod + rusanov + conservative). The native seam accepts limiter tokens - none / minmod / vanleer / weno5, Riemann fluxes rusanov / hll / hllc / roe, and + none / minmod / vanleer / weno5 / mc / superbee, Riemann fluxes + rusanov / hll / hllc / roe, and conservative / primitive variables. This low-level WENO5 stencil route is not an AMR availability guarantee: a resolved Case also requires an owner-qualified coarse/fine provider certified for order 5 and ghost depth 3. The native catalogue contains that diff --git a/python/pops/runtime/_analytic_expression_lowering.py b/python/pops/runtime/_analytic_expression_lowering.py index 7cf0a96c4..5e38552c0 100644 --- a/python/pops/runtime/_analytic_expression_lowering.py +++ b/python/pops/runtime/_analytic_expression_lowering.py @@ -37,6 +37,7 @@ def lower_analytic_components( *, frame_id: str, bindings: Any = None, + time_clock_id: str | None = None, ) -> tuple[tuple[tuple[str, ...], tuple[float, ...]], ...]: """Return one validated postfix opcode/literal pair per scalar component.""" @@ -51,6 +52,7 @@ def lower_analytic_components( frame_id=frame_id, where="components[%d]" % index, bindings=bindings, + time_clock_id=time_clock_id, ) for index, expression in enumerate(components) ) @@ -62,6 +64,7 @@ def _lower_expression( frame_id: str, where: str, bindings: Any, + time_clock_id: str | None, ) -> tuple[tuple[str, ...], tuple[float, ...]]: from pops.analytic import ScalarExpr @@ -74,8 +77,8 @@ def _lower_expression( budget = [0] _lower_node( data["root"], expected="scalar", frame_id=frame_id, where=where + ".root", - depth=1, budget=budget, opcodes=opcodes, literals=literals, - bindings=bindings, + depth=1, budget=budget, opcodes=opcodes, literals=literals, bindings=bindings, + time_clock_id=time_clock_id, ) if len(opcodes) != len(literals) or not opcodes: raise RuntimeError("analytic lowering produced an invalid postfix program") @@ -93,6 +96,7 @@ def _lower_node( opcodes: list[str], literals: list[float], bindings: Any, + time_clock_id: str | None, ) -> None: if depth > _MAX_DEPTH: raise ValueError("%s exceeds analytic max_depth=%d" % (where, _MAX_DEPTH)) @@ -147,6 +151,23 @@ def _lower_node( literals.append(float(value_id)) return + if kind == "scalar" and op == "time": + if set(data) != {"kind", "op", "clock", "clock_id"}: + raise TypeError("%s time node has an unsupported shape" % where) + if not isinstance(time_clock_id, str) or not time_clock_id: + raise NotImplementedError( + "%s requires a consuming runtime with one exact physical-time Clock" % where + ) + if data["clock_id"] != time_clock_id: + raise ValueError("%s time belongs to another logical Clock" % where) + from pops.time import Clock + + if Clock.from_data(data["clock"]).qualified_id != time_clock_id: + raise ValueError("%s time Clock data does not authenticate clock_id" % where) + opcodes.append("input") + literals.append(0.0) + return + if set(data) != {"kind", "op", "arguments"} \ or not isinstance(data["arguments"], (tuple, list)): raise TypeError("%s operator node has an unsupported shape" % where) @@ -179,8 +200,8 @@ def _lower_node( _lower_node( argument, expected=child_kind, frame_id=frame_id, where="%s.arguments[%d]" % (where, index), depth=depth + 1, budget=budget, - opcodes=opcodes, literals=literals, - bindings=bindings, + opcodes=opcodes, literals=literals, bindings=bindings, + time_clock_id=time_clock_id, ) # The canonical schema vocabulary is also the native ABI vocabulary. Keeping one spelling # prevents the Python and C++ validators from accepting disjoint instruction sets. diff --git a/python/pops/runtime/_bricks_scheme.py b/python/pops/runtime/_bricks_scheme.py index 7ee99202b..2ceb33140 100644 --- a/python/pops/runtime/_bricks_scheme.py +++ b/python/pops/runtime/_bricks_scheme.py @@ -65,7 +65,8 @@ def __init__(self, a: Any, b: Any, rate: Any) -> None: _RECON_SCHEMES = { # variables descriptor scheme -> Spatial.recon route "conservative": RECON_CONSERVATIVE, "primitive": RECON_PRIMITIVE, } -_LIMITER_SUGGEST = ("pops.numerics.reconstruction.limiters.Minmod() / .VanLeer(), " +_LIMITER_SUGGEST = ("pops.numerics.reconstruction.limiters.Minmod() / .VanLeer() / .MC() / " + ".Superbee(), " "pops.numerics.reconstruction.FirstOrder() / WENO5() / MUSCL(...)") _FLUX_SUGGEST = "pops.numerics.riemann.Rusanov() / HLL() / HLLC() / Roe()" _RECON_SUGGEST = "pops.numerics.variables.Conservative() / Primitive()" @@ -134,9 +135,10 @@ class Spatial: weno5=/primitive=) stay as typed-flag sugar. - ``limiter`` (Spec 5 sec.14.1 alias: ``reconstruction``): a reconstruction / limiter descriptor - lowering to "none" | "minmod" | "vanleer" | "weno5". + lowering to "none" | "minmod" | "vanleer" | "mc" | "superbee" | "weno5". ``pops.numerics.reconstruction.FirstOrder()`` -> none, ``.limiters.Minmod()`` / - ``.VanLeer()``, ``.WENO5()`` / ``.WENO5Z()`` -> weno5, ``.MUSCL(limiter=...)`` -> its limiter. + ``.VanLeer()`` / ``.MC()`` / ``.Superbee()``, ``.WENO5()`` / ``.WENO5Z()`` -> weno5, + ``.MUSCL(limiter=...)`` -> its limiter. weno5 = WENO5-Z, order 5 in smooth regions, 5-point stencil (3 ghosts), oscillation-free capture near a front; only the native ``add_block`` path exposes it (the compiled .so paths allocate 2 ghosts -> explicit rejection). diff --git a/python/pops/runtime/_bricks_time.py b/python/pops/runtime/_bricks_time.py index 1b1f61b39..715f77dee 100644 --- a/python/pops/runtime/_bricks_time.py +++ b/python/pops/runtime/_bricks_time.py @@ -19,6 +19,9 @@ class Role: """Stable physical roles shared by descriptors and symbolic Program authoring.""" + AxialX = "axial_x" + AxialY = "axial_y" + AxialZ = "axial_z" Density = "density" MomentumX = "momentum_x" MomentumY = "momentum_y" diff --git a/python/pops/runtime/_generated_component_routes.py b/python/pops/runtime/_generated_component_routes.py index c3f59fdd2..d28cc27ab 100644 --- a/python/pops/runtime/_generated_component_routes.py +++ b/python/pops/runtime/_generated_component_routes.py @@ -9,11 +9,11 @@ CAPABILITY_VOCAB_VERSION = 4 -COMPONENT_CATALOG_SHA256 = '5c67c081cf1808138583ed00856e6601c12384ae28e9c0f8cc7b8ce004c3b0f6' +COMPONENT_CATALOG_SHA256 = 'a10653b4730d0e5a8d8b1c21d3bb4263f3ca8fc93ebdfb1af59b88c7cbce07f0' -COMPONENT_CATALOG_SEMANTIC_SHA256 = 'adbb3693dc17eff5aa7b78415df35f011dfd2c64fc26eb9a98200923e52c47ea' +COMPONENT_CATALOG_SEMANTIC_SHA256 = '9d7d38624833a7a7d7462ac7113bbdc215e6bce145ad1150b8cc1decf2d297b1' -ROUTE_REGISTRY_SIGNATURE = 'v2:adbb3693dc17eff5aa7b78415df35f011dfd2c64fc26eb9a98200923e52c47ea' +ROUTE_REGISTRY_SIGNATURE = 'v2:9d7d38624833a7a7d7462ac7113bbdc215e6bce145ad1150b8cc1decf2d297b1' ROUTE_TABLES = {'riemann': (('rusanov', 'pops::RusanovFlux', @@ -40,7 +40,9 @@ 'limiter': (('none', 'pops::NoSlope', (), ()), ('minmod', 'pops::Minmod', (), ()), ('vanleer', 'pops::VanLeer', (), ()), - ('weno5', 'pops::Weno5', ('3-cell halo',), ())), + ('weno5', 'pops::Weno5', ('3-cell halo',), ()), + ('mc', 'pops::MC', (), ()), + ('superbee', 'pops::Superbee', (), ())), 'recon': (('conservative', 'pops::make_block(recon_prim=false)', (), ()), ('primitive', 'pops::make_block(recon_prim=true)', @@ -125,7 +127,9 @@ 'limiter': {'none': {'n_ghost': 1, 'formal_order': 1, 'muscl_compatible': False}, 'minmod': {'n_ghost': 2, 'formal_order': 2, 'muscl_compatible': True}, 'vanleer': {'n_ghost': 2, 'formal_order': 2, 'muscl_compatible': True}, - 'weno5': {'n_ghost': 3, 'formal_order': 5, 'muscl_compatible': False}}, + 'weno5': {'n_ghost': 3, 'formal_order': 5, 'muscl_compatible': False}, + 'mc': {'n_ghost': 2, 'formal_order': 2, 'muscl_compatible': True}, + 'superbee': {'n_ghost': 2, 'formal_order': 2, 'muscl_compatible': True}}, 'recon': {'conservative': {}, 'primitive': {}}, 'time': {'explicit': {}, 'ssprk3': {}, 'euler': {}, 'imex': {}, 'imexrk_ars222': {}}, 'field_solver': {'geometric_mg': {}, 'fft': {}, 'fft_spectral': {}, 'polar': {}}, @@ -168,7 +172,7 @@ 'ids': ('kRusanov', 'kHll', 'kHllc', 'kRoe')}, 'limiter': {'enum': 'LimiterRouteId', 'table': 'kLimiterRoutes', - 'ids': ('kNone', 'kMinmod', 'kVanLeer', 'kWeno5')}, + 'ids': ('kNone', 'kMinmod', 'kVanLeer', 'kWeno5', 'kMc', 'kSuperbee')}, 'recon': {'enum': 'ReconRouteId', 'table': 'kReconRoutes', 'ids': ('kConservative', 'kPrimitive')}, 'time': {'enum': 'TimeRouteId', 'table': 'kTimeRoutes', diff --git a/python/pops/runtime/_runtime_authorities.py b/python/pops/runtime/_runtime_authorities.py index f1ae21307..e554e6a10 100644 --- a/python/pops/runtime/_runtime_authorities.py +++ b/python/pops/runtime/_runtime_authorities.py @@ -109,6 +109,7 @@ def _install_boundary_authorities(engine: Any, install_plan: Any) -> None: execution_data = component_execution_data(install_plan.execution_context) component_installers = { "apply_region_batch": getattr(native, "_install_ghost_boundary_component", None), + "transform_faces": getattr(native, "_install_boundary_flux_component", None), "residual": getattr(native, "_install_field_boundary_residual_component", None), "jvp": getattr(native, "_install_field_boundary_jvp_component", None), } @@ -158,16 +159,91 @@ def _install_boundary_authorities(engine: Any, install_plan: Any) -> None: or [row.get("ordinal") for row in faces] != [0, 1, 2, 3]: raise ValueError("prepared boundary plan must contain canonical xlo/xhi/ylo/yhi rows") types = [row.get("type") for row in faces] - if any(value not in {"periodic", "foextrap", "dirichlet", "external"} + if any(value not in { + "periodic", "foextrap", "dirichlet", "slip_wall", "external"} for value in types): raise NotImplementedError("prepared boundary plan selected an unavailable face producer") + representations = [row.get("representation", "conservative") for row in faces] + converter_identities = [row.get("converter") for row in faces] + for face, (face_type, representation, converter) in enumerate(zip( + types, representations, converter_identities, strict=True)): + if representation == "conservative": + if converter is not None: + raise ValueError( + "prepared conservative boundary face must not carry a converter") + elif representation == "primitive": + if face_type != "dirichlet" or not isinstance(converter, str) or not converter: + raise ValueError( + "prepared primitive boundary face %d requires an exact fixed-state " + "converter identity" % face) + else: + raise NotImplementedError( + "prepared boundary selected unavailable representation %r" % representation) + face_identities = [row.get("producer") for row in faces] + if any(not isinstance(value, str) or not value for value in face_identities): + raise TypeError( + "prepared boundary faces require non-empty owner-qualified producer identities") + component_roles = getattr(component, "cons_roles", None) + if not isinstance(component_roles, (list, tuple)) \ + or len(component_roles) != ncomp \ + or any(not isinstance(role, str) or not role for role in component_roles): + raise TypeError( + "compiled block must expose one authenticated physical role per component") values = [] + analytic_opcodes = [] + analytic_literals = [] + analytic_clocks = [] + plan_clocks = set() for comp in range(ncomp): for row in faces: row_values = row.get("values") if not isinstance(row_values, list) or len(row_values) != ncomp: raise ValueError("prepared boundary face values must exactly cover every component") values.append(float(row_values[comp])) + for face, row in enumerate(faces): + programs = row.get("analytic_programs", []) + clock = row.get("analytic_clock") + if not isinstance(programs, list) or len(programs) not in (0, ncomp): + raise ValueError( + "prepared boundary analytic programs must be empty or cover every component" + ) + if programs and (types[face] != "dirichlet" or representations[face] != "conservative"): + raise NotImplementedError( + "prepared analytic boundary programs require conservative fixed-state inflow" + ) + if clock is not None and (not isinstance(clock, str) or not clock or not programs): + raise TypeError( + "prepared boundary analytic Clock must be non-empty text on an analytic face" + ) + analytic_clocks.append("" if clock is None else clock) + if clock is not None: + plan_clocks.add(clock) + for component in range(ncomp): + if not programs: + analytic_opcodes.append([]) + analytic_literals.append([]) + continue + program = programs[component] + if not isinstance(program, dict) or set(program) != {"opcodes", "literals"}: + raise TypeError( + "prepared boundary analytic program must contain opcodes and literals" + ) + opcodes = program["opcodes"] + literals = program["literals"] + if ( + not isinstance(opcodes, list) + or not opcodes + or any(not isinstance(opcode, str) or not opcode for opcode in opcodes) + or not isinstance(literals, list) + or len(literals) != len(opcodes) + ): + raise ValueError( + "prepared boundary analytic opcode/literal rows must be non-empty and aligned" + ) + analytic_opcodes.append(opcodes) + analytic_literals.append([float(value) for value in literals]) + if len(plan_clocks) > 1: + raise ValueError("prepared analytic boundary plan cannot mix several logical Clocks") boundary_state_identity = _canonical_qualified_id( first.get("state"), where="prepared boundary state") if boundary_state_identity != state_identity: @@ -182,10 +258,16 @@ def _install_boundary_authorities(engine: Any, install_plan: Any) -> None: required_depth, types, values, - ncomp, + face_identities, + list(component_roles), list(first.get("omitted_interface_faces", [])), state_identity, periodic_identifications, + representations, + ["" if value is None else value for value in converter_identities], + analytic_opcodes, + analytic_literals, + analytic_clocks, ) component_rows = first.get("component_regions", []) if not isinstance(component_rows, list): @@ -252,6 +334,12 @@ def _install_boundary_authorities(engine: Any, install_plan: Any) -> None: if operation in {"residual", "jvp"} and len(row["outputs"]) != 1: raise NotImplementedError( "native boundary residual/JVP currently requires one exact mutable output") + if operation == "transform_faces" and ( + len(row["outputs"]) != 1 or + row["outputs"][0] != row["state_identity"] or row["directions"]): + raise NotImplementedError( + "native post-Riemann boundary flux requires one exact state output and no " + "JVP direction table") component_jobs.append(( install_component, block.name, diff --git a/python/pops/runtime/_system_install.py b/python/pops/runtime/_system_install.py index b33ae6b92..54b90299d 100644 --- a/python/pops/runtime/_system_install.py +++ b/python/pops/runtime/_system_install.py @@ -41,6 +41,14 @@ _System = object +def _reject_unpublished_newton_diagnostics(time: Any, *, where: str) -> None: + if getattr(time, "newton_diagnostics", False): + raise ValueError( + f"{where}: newton_diagnostics=True is unavailable on the Program-only System " + "runtime because no typed implicit Program consumer publishes that report" + ) + + class _SystemInstall(_System): """Block/equation/coupling installation methods of System.""" @@ -75,6 +83,7 @@ def add_block(self, name: Any, model: Any, spatial: Any = None, time: Any = None _guard_assembling(self, "add_block") # frozen once pops.bind completes (ADC-592) spatial = spatial if spatial is not None else Spatial() time = time if time is not None else Explicit() + _reject_unpublished_newton_diagnostics(time, where="System.add_block") # Native ABI conversion happens here; descriptors above this seam stay exact. rel_tol, abs_tol, fd_eps, damping, positivity_floor = native_block_scalars( time, spatial, where="System.add_block") @@ -117,6 +126,7 @@ def add_equation(self, name: Any, model: Any, spatial: Any = None, time: Any = N spatial = spatial if spatial is not None else Spatial() time = time if time is not None else Explicit() + _reject_unpublished_newton_diagnostics(time, where="System.add_equation") nsub = positive_int(substeps if substeps is not None else getattr(time, "substeps", 1), where="System.add_equation.substeps") nstride = positive_int(stride if stride is not None else getattr(time, "stride", 1), where="System.add_equation.stride") diff --git a/python/pops/runtime/amr/_view.py b/python/pops/runtime/amr/_view.py index e10374096..29c2980de 100644 --- a/python/pops/runtime/amr/_view.py +++ b/python/pops/runtime/amr/_view.py @@ -172,7 +172,7 @@ def explain_ghosts(self) -> Any: per_level_depth=None, requirement_note=( "the reconstruction stencil sets the ghost depth " - "(minmod / vanleer -> 1, weno5 -> 3); the coarse-fine fine ghosts " + "(minmod / vanleer / mc / superbee -> 1, weno5 -> 3); the coarse-fine fine ghosts " "are re-derived per path on the AMR transport." ), notes=["per-level ghost depth is not exposed by this native build."], diff --git a/python/pops/runtime/doctor.py b/python/pops/runtime/doctor.py index 2972134b9..86c37d17b 100644 --- a/python/pops/runtime/doctor.py +++ b/python/pops/runtime/doctor.py @@ -17,7 +17,7 @@ # descriptor catalogs (see _descriptor_tokens); this only pins the display order so the audit # table reads the same every run (and the test_capabilities contract keeps its ordered lists). _RIEMANN_ORDER = ("rusanov", "hll", "hllc", "roe") -_LIMITER_ORDER = ("none", "minmod", "vanleer", "weno5") +_LIMITER_ORDER = ("none", "minmod", "vanleer", "weno5", "mc", "superbee") # Riemann fluxes wired on the polar geometry: rusanov (any model) + hll (isothermal fluid declares # wave_speeds). hllc/roe have no polar energy-flux brick (make_block_polar rejects them), so the # polar row is the catalog intersected with this allow-list -- a removed flux cannot leave a phantom @@ -40,8 +40,9 @@ def _descriptor_tokens() -> Any: the internal descriptor catalog report walks (riemann / limiter / reconstruction / elliptic solvers), so adding or retiring a descriptor cannot silently desync the doctor matrix from the introspectable capability matrix. Only descriptors that declare themselves available - are reported (a planned-but-not-native brick like ``mc`` / ``superbee`` is left out). Pure: no - ``_pops`` import, no numeric loop. + are reported; MC and Superbee are ordinary native limiter descriptors and therefore appear + through this same path without a doctor-specific allowlist. Pure: no ``_pops`` import, no + numeric loop. """ from pops.numerics.reconstruction import reconstruction from pops.numerics.reconstruction.limiters import limiters diff --git a/python/pops/runtime/program_report.py b/python/pops/runtime/program_report.py index 6194a5727..28fa57b14 100644 --- a/python/pops/runtime/program_report.py +++ b/python/pops/runtime/program_report.py @@ -42,13 +42,28 @@ class ProgramRuntimeReport: sections); a bound program fills the sections from the C++ Program subsystem accessors. """ - schema_version = 3 + schema_version = 4 report_type = "program_runtime" - def __init__(self, *, installed: Any, program_hash: Any, step_transaction: Any, block_map: Any, - params: Any, diagnostics: Any, histories: Any, cache: Any, - profiler: Any, clocks: Any, level_relations: Any, - flux_ledger: Any, synchronization: Any, temporal: Any) -> None: + def __init__( + self, + *, + installed: Any, + program_hash: Any, + step_transaction: Any, + block_map: Any, + params: Any, + diagnostics: Any, + histories: Any, + cache: Any, + profiler: Any, + clocks: Any, + level_relations: Any, + flux_ledger: Any, + synchronization: Any, + temporal_partition: Any, + temporal: Any, + ) -> None: self.installed = bool(installed) self.program_hash = program_hash or "" self.step_transaction = dict(step_transaction) @@ -62,6 +77,7 @@ def __init__(self, *, installed: Any, program_hash: Any, step_transaction: Any, self.level_relations = [dict(row) for row in level_relations] self.flux_ledger = [dict(row) for row in flux_ledger] self.synchronization = [dict(row) for row in synchronization] + self.temporal_partition = dict(temporal_partition) self.temporal = dict(temporal) def to_dict(self) -> Any: @@ -81,6 +97,7 @@ def to_dict(self) -> Any: "level_relations": [dict(row) for row in self.level_relations], "flux_ledger": [dict(row) for row in self.flux_ledger], "synchronization": [dict(row) for row in self.synchronization], + "temporal_partition": dict(self.temporal_partition), "temporal": dict(self.temporal), } @@ -93,9 +110,12 @@ def to_json(self, path: Any = None, *, indent: int = 2) -> Any: return text def __repr__(self) -> Any: - return ("ProgramRuntimeReport(installed=%r, hash=%r, histories=%d, cache=%d)" - % (self.installed, self.program_hash or "(none)", len(self.histories), - len(self.cache))) + return "ProgramRuntimeReport(installed=%r, hash=%r, histories=%d, cache=%d)" % ( + self.installed, + self.program_hash or "(none)", + len(self.histories), + len(self.cache), + ) def __str__(self) -> Any: strategy = self.step_transaction.get("strategy", {}) @@ -112,6 +132,7 @@ def __str__(self) -> Any: lines.append(" clocks : %d cursor(s)" % len(self.clocks)) lines.append(" flux ledger : %d accepted contribution(s)" % len(self.flux_ledger)) lines.append(" sync : %d phase event(s)" % len(self.synchronization)) + lines.append(" partition : %s" % (self.temporal_partition.get("kind") or "(none)")) return "\n".join(lines) @@ -121,6 +142,7 @@ def _params(sim: Any) -> Any: count 0. The limit (ADC-610) surfaces the previously-hidden fixed-array capacity so a block's headroom is introspectable.""" from pops.physics.aux import max_runtime_params # lazy: keep the report import-light + limit = max_runtime_params() rows = [] block_map = list(_call(sim, "program_block_map", []) or []) @@ -135,36 +157,44 @@ def _params(sim: Any) -> Any: def _histories(sim: Any) -> Any: rows = [] for name in _call(sim, "history_names", []) or []: - rows.append({ - "name": name, - "depth": _call(sim, "history_depth", None, name), - "ncomp": _call(sim, "history_ncomp", None, name), - "initialized": _call(sim, "history_initialized", None, name), - }) + rows.append( + { + "name": name, + "depth": _call(sim, "history_depth", None, name), + "ncomp": _call(sim, "history_ncomp", None, name), + "initialized": _call(sim, "history_initialized", None, name), + } + ) return rows def _cache(sim: Any) -> Any: rows = [] for node_id in _call(sim, "program_cache_nodes", []) or []: - rows.append({ - "node_id": int(node_id), - "name": _call(sim, "program_cache_name", "", node_id), - "last_update_step": _call(sim, "program_cache_last_update_step", None, node_id), - "accumulated_dt": _call(sim, "program_cache_accumulated_dt", None, node_id), - }) + rows.append( + { + "node_id": int(node_id), + "name": _call(sim, "program_cache_name", "", node_id), + "last_update_step": _call(sim, "program_cache_last_update_step", None, node_id), + "accumulated_dt": _call(sim, "program_cache_accumulated_dt", None, node_id), + } + ) return rows -def _amr_temporal_report(sim: Any) -> tuple[Any, Any, Any, Any]: +def _amr_temporal_report(sim: Any) -> tuple[Any, Any, Any, Any, Any]: clocks = [] for row in _call(sim, "program_clock_manifest", []) or []: if row[0] == "level" and len(row) == 6: - clocks.append({ - "kind": "level", "level": int(row[1]), "macro_step": int(row[2]), - "phase": {"numerator": int(row[3]), "denominator": int(row[4])}, - "physical_time": float(row[5]), - }) + clocks.append( + { + "kind": "level", + "level": int(row[1]), + "macro_step": int(row[2]), + "phase": {"numerator": int(row[3]), "denominator": int(row[4])}, + "physical_time": float(row[5]), + } + ) elif row[0] == "logical" and len(row) == 3: clocks.append({"kind": "logical", "clock": row[1], "tick": int(row[2])}) else: @@ -173,33 +203,66 @@ def _amr_temporal_report(sim: Any) -> tuple[Any, Any, Any, Any]: for row in _call(sim, "checkpoint_temporal_relations", []) or []: if len(row) != 5: raise ValueError("native AMR temporal relation report has an invalid row") - relations.append({ - "parent_level": int(row[0]), "child_level": int(row[1]), - "temporal_ratio": {"numerator": int(row[2]), "denominator": int(row[3])}, - "remainder_policy": row[4], - }) + relations.append( + { + "parent_level": int(row[0]), + "child_level": int(row[1]), + "temporal_ratio": {"numerator": int(row[2]), "denominator": int(row[3])}, + "remainder_policy": row[4], + } + ) ledger = [] for row in _call(sim, "program_flux_ledger_manifest", []) or []: if len(row) != 13: raise ValueError("native AMR Program flux-ledger report has an invalid row") - ledger.append({ - "owner": row[0], "state": row[1], "rate": row[2], "flux": row[3], - "level": int(row[4]), "macro_step": int(row[5]), - "phase": {"numerator": int(row[6]), "denominator": int(row[7])}, - "stage_weight": {"numerator": int(row[8]), "denominator": int(row[9])}, - "orientation": row[10], "face_measure": float(row[11]), - "substep_duration": float(row[12]), - }) + ledger.append( + { + "owner": row[0], + "state": row[1], + "rate": row[2], + "flux": row[3], + "level": int(row[4]), + "macro_step": int(row[5]), + "phase": {"numerator": int(row[6]), "denominator": int(row[7])}, + "stage_weight": {"numerator": int(row[8]), "denominator": int(row[9])}, + "orientation": row[10], + "face_measure": float(row[11]), + "substep_duration": float(row[12]), + } + ) synchronization = [] for row in _call(sim, "program_sync_manifest", []) or []: if len(row) != 7: raise ValueError("native AMR Program synchronization report has an invalid row") - synchronization.append({ - "parent_level": int(row[0]), "child_level": int(row[1]), - "block": int(row[2]), "phase": row[3], "macro_step": int(row[4]), - "clock_phase": {"numerator": int(row[5]), "denominator": int(row[6])}, - }) - return clocks, relations, ledger, synchronization + synchronization.append( + { + "parent_level": int(row[0]), + "child_level": int(row[1]), + "block": int(row[2]), + "phase": row[3], + "macro_step": int(row[4]), + "clock_phase": {"numerator": int(row[5]), "denominator": int(row[6])}, + } + ) + temporal_partition = {} + for row in _call(sim, "program_temporal_partition_manifest", []) or []: + if row[0] == "summary" and len(row) == 7: + if temporal_partition: + raise ValueError("native temporal-partition report has duplicate summary rows") + temporal_partition = { + "kind": row[1], + "provider_identity": row[2], + "topology_epoch": int(row[3]), + "synchronization_tick": int(row[4]), + "tick_denominator": int(row[5]), + "cell_count": int(row[6]), + "rungs": [], + } + elif row[0] == "rung" and len(row) == 3 and temporal_partition: + temporal_partition["rungs"].append({"rung": int(row[1]), "cells": int(row[2])}) + else: + raise ValueError("native temporal-partition report has an invalid row") + return clocks, relations, ledger, synchronization, temporal_partition def build_program_report(sim: Any) -> Any: @@ -210,7 +273,7 @@ def build_program_report(sim: Any) -> Any: missing an accessor yields ``None`` for that field. """ program_hash = _call(sim, "installed_program_hash", "") or "" - clocks, relations, ledger, synchronization = _amr_temporal_report(sim) + clocks, relations, ledger, synchronization, temporal_partition = _amr_temporal_report(sim) temporal_state = getattr(sim, "_temporal_restart_state", None) temporal = temporal_state.to_data() if temporal_state is not None else {} return ProgramRuntimeReport( @@ -218,7 +281,8 @@ def build_program_report(sim: Any) -> Any: program_hash=program_hash, step_transaction=( sim._step_transaction_plan.to_data() - if getattr(sim, "_step_transaction_plan", None) is not None else {} + if getattr(sim, "_step_transaction_plan", None) is not None + else {} ), block_map=list(_call(sim, "program_block_map", []) or []), params=_params(sim), @@ -230,5 +294,6 @@ def build_program_report(sim: Any) -> Any: level_relations=relations, flux_ledger=ledger, synchronization=synchronization, + temporal_partition=temporal_partition, temporal=temporal, ) diff --git a/python/pops/runtime/routes.py b/python/pops/runtime/routes.py index aadad5e60..e02a0eeec 100644 --- a/python/pops/runtime/routes.py +++ b/python/pops/runtime/routes.py @@ -276,6 +276,8 @@ def route_registry_hash() -> str: LIMITER_MINMOD = _REGISTRY["limiter"]["minmod"] LIMITER_VANLEER = _REGISTRY["limiter"]["vanleer"] LIMITER_WENO5 = _REGISTRY["limiter"]["weno5"] +LIMITER_MC = _REGISTRY["limiter"]["mc"] +LIMITER_SUPERBEE = _REGISTRY["limiter"]["superbee"] RECON_CONSERVATIVE = _REGISTRY["recon"]["conservative"] RECON_PRIMITIVE = _REGISTRY["recon"]["primitive"] diff --git a/python/pops/time/points.py b/python/pops/time/points.py index 00c9737bc..9bfdd93f7 100644 --- a/python/pops/time/points.py +++ b/python/pops/time/points.py @@ -53,6 +53,21 @@ def to_data(self) -> dict[str, Any]: "owner": self.owner.to_data() if self.owner is not None else None, } + @classmethod + def from_data(cls, data: Any) -> Clock: + """Strict inverse of :meth:`to_data` for data-only clock consumers.""" + required = {"schema_version", "name", "owner"} + if not isinstance(data, Mapping) or set(data) != required: + raise TypeError("Clock data has an unsupported shape") + if data["schema_version"] != 1: + raise ValueError("Clock data uses an unsupported schema version") + owner_data = data["owner"] + owner = None if owner_data is None else OwnerPath.from_data(owner_data) + result = cls(data["name"], owner=owner) + if result.to_data() != dict(data): + raise ValueError("Clock data is not canonical") + return result + @dataclass(frozen=True, slots=True, init=False) class TimePoint: diff --git a/schemas/component_catalog.v2.json b/schemas/component_catalog.v2.json index 1860f366f..18002fbd2 100644 --- a/schemas/component_catalog.v2.json +++ b/schemas/component_catalog.v2.json @@ -158,6 +158,16 @@ "facets": ["stencil", "lowering"], "operations": ["apply"] }, + { + "id": 6, + "name": "boundary_flux", + "uri": "pops://interfaces/boundary-flux", + "version": 1, + "cpp_table": "PopsBoundaryFluxApiV1", + "hot_path": true, + "facets": ["provider", "lowering", "fallible_evaluation"], + "operations": ["transform_faces"] + }, { "id": 7, "name": "field_solver", @@ -206,6 +216,10 @@ "interface": "numerical_flux", "operation": "evaluate_faces" }, + "boundary_flux_provider": { + "interface": "boundary_flux", + "operation": "transform_faces" + }, "residual_operator": { "interface": "field_boundary_closure", "operation": "residual" @@ -575,6 +589,34 @@ "formal_order": 5, "muscl_compatible": false } + }, + { + "token": "mc", + "wire_id": 4, + "cpp_id": "kMc", + "native_entry": "pops::MC", + "requirements": [], + "limitations": [], + "aliases": [], + "metadata": { + "n_ghost": 2, + "formal_order": 2, + "muscl_compatible": true + } + }, + { + "token": "superbee", + "wire_id": 5, + "cpp_id": "kSuperbee", + "native_entry": "pops::Superbee", + "requirements": [], + "limitations": [], + "aliases": [], + "metadata": { + "n_ghost": 2, + "formal_order": 2, + "muscl_compatible": true + } } ] }, diff --git a/scripts/generate_component_catalog.py b/scripts/generate_component_catalog.py index 90d861deb..ce95300ff 100644 --- a/scripts/generate_component_catalog.py +++ b/scripts/generate_component_catalog.py @@ -925,6 +925,35 @@ def _render_component_abi(catalog: dict[str, Any], digest: str) -> str: PopsApplyRegionBatchFnV1 apply_region_batch; }} PopsGhostBoundaryApiV1; +typedef struct PopsBoundaryFluxRequestV1 {{ + uint32_t struct_size; + const char* provider_identity; + const char* state_identity; + PopsConstFieldViewV1 base_outward_normal_flux; + PopsConstFieldViewV1 coordinates; + PopsConstFieldViewV1 outward_normals; + const double* face_measures; + PopsBoundaryRegionV1 region; + size_t dependency_count; + const PopsQualifiedConstFieldV1* dependencies; + size_t parameter_count; + const PopsQualifiedScalarV1* parameters; + PopsLogicalTimeV1 logical_time; + PopsExecutionContextV1 execution; +}} PopsBoundaryFluxRequestV1; +typedef struct PopsBoundaryFluxResultV1 {{ + uint32_t struct_size; + PopsFieldViewV1 outward_normal_flux; + PopsComponentActionV1* actions; + PopsComponentStatusV1 status; +}} PopsBoundaryFluxResultV1; +typedef int32_t (*PopsTransformBoundaryFacesFnV1)( + void*, const PopsBoundaryFluxRequestV1*, PopsBoundaryFluxResultV1*); +typedef struct PopsBoundaryFluxApiV1 {{ + PopsComponentTableHeaderV1 header; + PopsTransformBoundaryFacesFnV1 transform_faces; +}} PopsBoundaryFluxApiV1; + typedef struct PopsFieldBoundaryRequestV1 {{ uint32_t struct_size; const char* closure_identity; diff --git a/scripts/run_adc757_prepared_numerics_gate.py b/scripts/run_adc757_prepared_numerics_gate.py new file mode 100755 index 000000000..465c3147f --- /dev/null +++ b/scripts/run_adc757_prepared_numerics_gate.py @@ -0,0 +1,413 @@ +#!/usr/bin/env python3 +"""Validate and run the bounded ADC-757 prepared-numerics evidence gate.""" + +from __future__ import annotations + +import argparse +import ast +from collections import Counter, defaultdict +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import tomllib +import xml.etree.ElementTree as ET + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_MANIFEST = ROOT / "tests/gates/adc757_prepared_numerics.toml" +TEST_MANIFEST = ROOT / "tests/test_manifest.toml" +EXPECTED_REQUIREMENTS = { + "prepared_local_nonlinear", + "typed_fallible_evaluation", + "transactional_recovery_publication", + "allocation_aware_cell_hot_path", + "prepared_boundary_publication", + "post_riemann_boundary_flux", + "qualified_flux_provider_pack", + "capability_driven_riemann", + "mpi_collective_execution", + "typed_flux_recovery_consumption", + "runtime_recovery_consumer_publication", + "type_erased_recovery_method_identity", + "model_declared_admissibility", + "prepared_limiter_provider", + "cell_local_temporal_partition_authority", + "python_ir_generated_abi_and_restart_parity", + "host_workspace_reentrancy", +} +EXPECTED_DEFERRED = ( + "remaining_3d_metric_eb_characteristic_and_spatial_provider_matrix", + "remaining_legacy_recovery_and_boundary_authority_deletion", + "amr_regrid_migration_and_restart_coherence", + "gpu_backend_execution", + "accelerator_stream_partitioning", + "performance_baselines_and_end_to_end_benchmarks", + "local_time_and_load_balance_provider_families", +) +GTEST_PATTERN = re.compile(r"\bTEST(?:_F)?\(\s*([A-Za-z_]\w*)\s*,\s*([A-Za-z_]\w*)\s*\)") + + +def _cpp_suites() -> dict[str, dict]: + data = tomllib.loads(TEST_MANIFEST.read_text(encoding="utf-8")) + return {str(row["name"]): row for row in data.get("cpp", {}).get("suite", ())} + + +def _python_files() -> set[str]: + data = tomllib.loads(TEST_MANIFEST.read_text(encoding="utf-8")) + files: set[str] = set() + for suite in data.get("python", {}).get("suite", ()): + relative_root = suite.get("path") + if not isinstance(relative_root, str): + continue + root = ROOT / relative_root + if not root.is_dir(): + continue + files.update( + source.relative_to(ROOT).as_posix() + for source in root.rglob("test_*.py") + if source.is_file() + ) + return files + + +def _declared_gtests(suite: dict) -> tuple[dict[str, bool], list[str]]: + tests: dict[str, bool] = {} + errors: list[str] = [] + for relative in suite.get("sources", ()): + source = ROOT / relative + if not source.is_file(): + errors.append("missing source %s" % relative) + continue + text = source.read_text(encoding="utf-8") + matches = list(GTEST_PATTERN.finditer(text)) + for index, match in enumerate(matches): + suite_name, test_name = match.groups() + name = "%s.%s" % (suite_name, test_name) + if name in tests: + errors.append("duplicate declared GTest %s" % name) + continue + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + body = text[match.start():end] + tests[name] = ( + suite_name.startswith("DISABLED_") + or test_name.startswith("DISABLED_") + or "GTEST_SKIP" in body + ) + return tests, errors + + +def _declared_pytests(relative: str) -> tuple[dict[str, ast.FunctionDef], list[str]]: + source = ROOT / relative + if not source.is_file(): + return {}, ["missing source %s" % relative] + try: + tree = ast.parse(source.read_text(encoding="utf-8"), filename=relative) + except (OSError, SyntaxError) as exc: + return {}, ["cannot parse %s: %s" % (relative, exc)] + tests = { + node.name: node + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name.startswith("test_") + } + return tests, [] + + +def _pytest_is_skipped(test: ast.FunctionDef) -> bool: + blocked_decorators = ("pytest.mark.skip", "pytest.mark.skipif", "pytest.mark.xfail") + if any( + any(blocked in ast.unparse(decorator) for blocked in blocked_decorators) + for decorator in test.decorator_list + ): + return True + return any( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "pytest" + and node.func.attr in {"skip", "xfail"} + for node in ast.walk(test) + ) + + +def validate_manifest(path: Path = DEFAULT_MANIFEST) -> tuple[dict, list[str]]: + """Return the manifest and deterministic source-only validation errors.""" + try: + data = tomllib.loads(path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError) as exc: + return {}, ["cannot read ADC-757 gate manifest %s: %s" % (path, exc)] + + errors: list[str] = [] + expected_fields = { + "schema_version", + "gate", + "issue", + "evidence_from", + "deferred", + "check", + } + if set(data) != expected_fields: + errors.append("manifest fields must be exactly %s" % sorted(expected_fields)) + if data.get("schema_version") != 2: + errors.append("schema_version must be exactly 2") + if data.get("gate") != "adc757-prepared-numerics-slice": + errors.append("gate must be exactly 'adc757-prepared-numerics-slice'") + if data.get("issue") != "ADC-757": + errors.append("issue must be exactly ADC-757") + expected_evidence = [ + "ADC-682", + "ADC-749", + "ADC-750", + "ADC-751", + "ADC-752", + "ADC-753", + "ADC-754", + "ADC-755", + "ADC-756", + ] + if data.get("evidence_from") != expected_evidence: + errors.append("evidence_from must be exactly %s" % expected_evidence) + if data.get("deferred") != list(EXPECTED_DEFERRED): + errors.append("deferred must enumerate every deliberately unproved family exactly") + + checks = data.get("check") + if not isinstance(checks, list) or not checks: + errors.append("manifest must contain [[check]] rows") + checks = [] + suites = _cpp_suites() + python_files = _python_files() + coverage: dict[str, set[str]] = defaultdict(set) + identities = Counter() + mpi_checks = 0 + for index, row in enumerate(checks, 1): + where = "check[%d]" % index + kind = row.get("kind", "ctest") + if kind == "pytest": + expected_row_fields = {"requirement", "polarity", "kind", "path", "test"} + else: + expected_row_fields = {"requirement", "polarity", "target", "test_regex"} + if kind == "mpi_ctest": + expected_row_fields.update({"kind", "nproc"}) + if set(row) != expected_row_fields: + errors.append("%s has unknown or missing fields" % where) + continue + requirement = row.get("requirement") + polarity = row.get("polarity") + target = row.get("target") + selector = row.get("test_regex") + if requirement not in EXPECTED_REQUIREMENTS: + errors.append("%s has unknown requirement %r" % (where, requirement)) + if polarity not in {"positive", "refusal"}: + errors.append("%s polarity must be positive or refusal" % where) + else: + coverage[str(requirement)].add(str(polarity)) + if kind == "pytest": + relative = row.get("path") + test_name = row.get("test") + identity = (kind, relative, test_name) + identities[identity] += 1 + if relative not in python_files: + errors.append("%s references unknown Python test file %r" % (where, relative)) + continue + declared, source_errors = _declared_pytests(str(relative)) + errors.extend("%s: %s" % (where, error) for error in source_errors) + if test_name not in declared: + errors.append( + "%s references unknown top-level pytest %r in %r" + % (where, test_name, relative) + ) + continue + if _pytest_is_skipped(declared[str(test_name)]): + errors.append("%s pytest proof %r is skipped or xfailed" % (where, test_name)) + continue + identity = (kind, target, selector) + identities[identity] += 1 + if ( + not isinstance(selector, str) + or not selector.startswith("^") + or not selector.endswith("$") + ): + errors.append("%s must use one anchored exact CTest regex" % where) + continue + if target not in suites: + errors.append("%s references unknown CTest target %r" % (where, target)) + continue + suite = suites[target] + labels = {str(label) for label in suite.get("labels", ())} + if kind == "mpi_ctest": + mpi_checks += 1 + nproc = row.get("nproc") + if "mpi" not in labels: + errors.append("%s mpi_ctest target %r lacks the mpi label" % (where, target)) + if ( + isinstance(nproc, bool) + or not isinstance(nproc, int) + or nproc < 1 + or nproc not in suite.get("mpi_nproc", ()) + ): + errors.append( + "%s nproc must be one exact rank count declared by %r" % (where, target) + ) + expected_selector = "^%s_np%s$" % (target, nproc) + if selector != expected_selector: + errors.append( + "%s mpi_ctest selector must be exactly %r" % (where, expected_selector) + ) + continue + if kind != "ctest": + errors.append("%s has unknown check kind %r" % (where, kind)) + continue + if "mpi" in labels or "gpu" in labels: + errors.append("%s ordinary CTest claims a deferred MPI/GPU target %r" % (where, target)) + declared, source_errors = _declared_gtests(suites[target]) + errors.extend("%s: %s" % (where, error) for error in source_errors) + try: + matches = sorted(name for name in declared if re.fullmatch(selector, name)) + except re.error as exc: + errors.append("%s has invalid test_regex: %s" % (where, exc)) + continue + if len(matches) != 1: + errors.append( + "%s must resolve to exactly one declared GTest; got %s" % (where, matches) + ) + elif declared[matches[0]]: + errors.append("%s selected CTest %r is skipped or disabled" % (where, matches[0])) + + duplicates = sorted(identity for identity, count in identities.items() if count > 1) + if duplicates: + errors.append("duplicate executable checks: %s" % duplicates) + if mpi_checks != 2: + errors.append( + "the closed mpi_collective_execution family requires exactly two MPI CTests" + ) + for requirement in sorted(EXPECTED_REQUIREMENTS): + missing = {"positive", "refusal"} - coverage[requirement] + if missing: + errors.append("%s lacks %s coverage" % (requirement, "/".join(sorted(missing)))) + return data, errors + + +def _run_ctest(build_dir: Path, target: str, selector: str) -> None: + listed = subprocess.run( + ["ctest", "--test-dir", str(build_dir), "-N", "-R", selector], + cwd=ROOT, + check=True, + text=True, + capture_output=True, + ) + if "Total Tests: 0" in listed.stdout or "Test #" not in listed.stdout: + raise RuntimeError( + "ADC-757 proof target %r (%s) is not built in %s" % (target, selector, build_dir) + ) + command = [ + "ctest", + "--test-dir", + str(build_dir), + "--output-on-failure", + "-R", + selector, + ] + print("+", " ".join(command), flush=True) + subprocess.run(command, cwd=ROOT, check=True) + + +def _pytest_skip_count(report: Path) -> int: + if not report.is_file(): + raise RuntimeError("ADC-757 pytest did not produce its mandatory JUnit report") + try: + root = ET.parse(report).getroot() + except ET.ParseError as exc: + raise RuntimeError("ADC-757 pytest produced an invalid JUnit report") from exc + return len(root.findall(".//skipped")) + + +def _run_pytest(relative: str, test_name: str) -> None: + environment = os.environ.copy() + environment["POPS_REQUIRE_MPI_TESTS"] = "1" + environment["POPS_REQUIRE_NATIVE_TESTS"] = "1" + with tempfile.TemporaryDirectory(prefix="pops-adc757-gate-") as temporary: + report = Path(temporary) / "pytest.xml" + command = [ + sys.executable, + "-m", + "pytest", + "-q", + "--strict-markers", + "-o", + "xfail_strict=true", + "--junitxml", + str(report), + "%s::%s" % (relative, test_name), + ] + print( + "+ POPS_REQUIRE_MPI_TESTS=1 POPS_REQUIRE_NATIVE_TESTS=1", + " ".join(command), + flush=True, + ) + completed = subprocess.run( + command, + cwd=ROOT, + env=environment, + check=False, + ) + skipped = _pytest_skip_count(report) + if skipped: + raise RuntimeError( + "ADC-757 pytest reported %d skipped/xfail proof(s); every proof is mandatory" + % skipped + ) + if completed.returncode != 0: + raise subprocess.CalledProcessError(completed.returncode, command) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) + parser.add_argument("--build-dir", type=Path, default=ROOT / "build") + parser.add_argument("--check-only", action="store_true") + parser.add_argument( + "--closure", + action="store_true", + help="require full ADC-757 closure (intentionally refused while deferred remains)", + ) + args = parser.parse_args(argv) + + data, errors = validate_manifest(args.manifest) + if errors: + print("ADC-757 prepared-numerics gate is invalid:", file=sys.stderr) + for error in errors: + print(" -", error, file=sys.stderr) + return 2 + print( + "ADC-757 prepared-numerics slice: OK " + "(%d executable proofs, %d explicitly deferred families)" + % (len(data["check"]), len(data["deferred"])) + ) + if args.closure: + print( + "ADC-757 closure refused: %d required families remain deferred" % len(data["deferred"]), + file=sys.stderr, + ) + return 3 + if args.check_only: + return 0 + checks = sorted( + data["check"], + key=lambda value: ( + value.get("kind", "ctest"), + value.get("target", value.get("path", "")), + value.get("test_regex", value.get("test", "")), + ), + ) + for row in checks: + if row.get("kind") == "pytest": + _run_pytest(row["path"], row["test"]) + else: + _run_ctest(args.build_dir, row["target"], row["test_regex"]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/runtime/amr/amr_system.cpp b/src/runtime/amr/amr_system.cpp index ad9dfd5a0..3adddf311 100644 --- a/src/runtime/amr/amr_system.cpp +++ b/src/runtime/amr/amr_system.cpp @@ -1330,72 +1330,76 @@ POPS_EXPORT void AmrSystem::install_block_state_route(const std::string& name, POPS_EXPORT void AmrSystem::install_boundary_plan( const std::string& name, const std::string& identity, int required_depth, - const std::vector& face_types, const std::vector& face_values, int ncomp, + const std::vector& face_types, const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces, const std::string& state_identity, PreparedBoundaryReadDependencies read_dependencies) { - install_boundary_plan(name, identity, required_depth, face_types, face_values, ncomp, - omitted_interface_faces, state_identity, std::move(read_dependencies), {}); + install_boundary_plan(name, identity, required_depth, face_types, face_values, face_identities, + component_roles, omitted_interface_faces, state_identity, + std::move(read_dependencies), {}); } POPS_EXPORT void AmrSystem::install_boundary_plan( const std::string& name, const std::string& identity, int required_depth, - const std::vector& face_types, const std::vector& face_values, int ncomp, + const std::vector& face_types, const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces, const std::string& state_identity, PreparedBoundaryReadDependencies read_dependencies, - std::vector periodic_identifications) { + std::vector periodic_identifications, + const std::vector& face_representations, + const std::vector& face_converter_identities, + const std::vector>& face_analytic_opcodes, + const std::vector>& face_analytic_literals, + const std::vector& face_analytic_clocks) { Impl* P = p_.get(); - require_assembling_amr(P->bound_, "install_boundary_plan"); - if (P->built) - throw std::runtime_error("AmrSystem::install_boundary_plan: system is already built"); - if (name.empty() || state_identity.empty() || P->boundary_plans_.count(name) != 0) - throw std::runtime_error( - "AmrSystem::install_boundary_plan requires unique block/state-qualified identities"); - const auto state_route = P->block_state_identities_.find(name); - if (state_route == P->block_state_identities_.end() || state_route->second != state_identity) - throw std::runtime_error( - "AmrSystem::install_boundary_plan state differs from the exact block state route"); - if (ncomp < 1 || face_types.size() != 4 || - face_values.size() != static_cast(4 * ncomp)) - throw std::runtime_error( - "AmrSystem::install_boundary_plan requires four face types and ncomp*4 values"); - auto parse = [](const std::string& token) { - if (token == "periodic") - return BCType::Periodic; - if (token == "foextrap") - return BCType::Foextrap; - if (token == "dirichlet") - return BCType::Dirichlet; - if (token == "external") - return BCType::External; - throw std::runtime_error("AmrSystem::install_boundary_plan: unsupported face producer '" + - token + "'"); - }; - std::vector components(static_cast(ncomp)); - for (int comp = 0; comp < ncomp; ++comp) { - BCRec& bc = components[static_cast(comp)]; - const BCType types[4] = {parse(face_types[0]), parse(face_types[1]), parse(face_types[2]), - parse(face_types[3])}; - const Real values[4] = {static_cast(face_values[static_cast(4 * comp)]), - static_cast(face_values[static_cast(4 * comp + 1)]), - static_cast(face_values[static_cast(4 * comp + 2)]), - static_cast(face_values[static_cast(4 * comp + 3)])}; - bc.xlo = types[0]; - bc.xhi = types[1]; - bc.ylo = types[2]; - bc.yhi = types[3]; - bc.xlo_val = values[0]; - bc.xhi_val = values[1]; - bc.ylo_val = values[2]; - bc.yhi_val = values[3]; - } - auto plan = std::make_shared( - identity, required_depth, std::move(components), omitted_interface_faces, state_identity, - std::move(read_dependencies), std::move(periodic_identifications)); - for (const auto& [_, installed] : P->boundary_plans_) - if (installed->state_identity() == state_identity) - throw std::runtime_error( - "AmrSystem::install_boundary_plan duplicate qualified state identity"); - P->boundary_plans_.emplace(name, std::move(plan)); + using BoundaryPlanMap = decltype(P->boundary_plans_); + using BoundaryPlanNode = typename BoundaryPlanMap::node_type; + BoundaryPlanNode prepared = analytic::collectively_prepare_exact_analytic_request( + "AmrSystem::install_boundary_plan", + [&]() -> BoundaryPlanNode { + require_assembling_amr(P->bound_, "install_boundary_plan"); + if (P->built) + throw std::runtime_error("AmrSystem::install_boundary_plan: system is already built"); + if (name.empty() || state_identity.empty() || P->boundary_plans_.count(name) != 0) + throw std::runtime_error( + "AmrSystem::install_boundary_plan requires unique block/state-qualified identities"); + const auto state_route = P->block_state_identities_.find(name); + if (state_route == P->block_state_identities_.end() || + state_route->second != state_identity) + throw std::runtime_error( + "AmrSystem::install_boundary_plan state differs from the exact block state route"); + for (const auto& [_, installed] : P->boundary_plans_) + if (installed->state_identity() == state_identity) + throw std::runtime_error( + "AmrSystem::install_boundary_plan duplicate qualified state identity"); + + auto hyperbolic = prepare_hyperbolic_boundary<2>( + face_types, face_values, face_identities, component_roles, + !periodic_identifications.empty(), face_representations, face_converter_identities, + face_analytic_opcodes, face_analytic_literals, face_analytic_clocks); + auto plan = std::make_shared( + identity, required_depth, std::move(hyperbolic), omitted_interface_faces, + state_identity, read_dependencies, periodic_identifications); + if (plan->has_mapped_periodicity()) + throw std::runtime_error( + "AmrSystem::install_boundary_plan: mapped periodic topology is not supported by AMR " + "fill-patch/regrid; use the uniform runtime or an axis-aligned translation"); + BoundaryPlanMap staged; + staged.emplace(name, std::move(plan)); + return staged.extract(staged.begin()); + }, + [&]() { + return detail::canonical_prepared_boundary_plan_request( + name, identity, required_depth, face_types, face_values, face_identities, + component_roles, omitted_interface_faces, state_identity, read_dependencies, + periodic_identifications, face_representations, face_converter_identities, + face_analytic_opcodes, face_analytic_literals, face_analytic_clocks); + }); + const auto published = P->boundary_plans_.insert(std::move(prepared)); + if (!published.inserted) + throw std::logic_error("AmrSystem::install_boundary_plan lost its prepared publication slot"); } POPS_EXPORT void AmrSystem::install_field_storage_route(const std::string& field_identity, @@ -1435,6 +1439,19 @@ POPS_EXPORT void AmrSystem::install_ghost_boundary_component( found->second->install_ghost_component(std::move(spec), std::move(component)); } +POPS_EXPORT void AmrSystem::install_boundary_flux_component( + const std::string& name, PreparedBoundaryComponentSpec spec, + std::shared_ptr component) { + Impl* P = p_.get(); + require_assembling_amr(P->bound_, "install_boundary_flux_component"); + if (P->built) + throw std::runtime_error("AmrSystem boundary flux: system is already built"); + const auto found = P->boundary_plans_.find(name); + if (found == P->boundary_plans_.end()) + throw std::runtime_error("AmrSystem boundary flux requires an installed block boundary plan"); + found->second->install_flux_component(std::move(spec), std::move(component)); +} + POPS_EXPORT void AmrSystem::install_field_boundary_residual_component( const std::string& name, PreparedBoundaryComponentSpec spec, std::shared_ptr component) { @@ -3338,6 +3355,15 @@ void AmrSystem::restore_checkpoint_accepted_state(const std::vectorruntime->topology_epoch()) + throw std::runtime_error( + "AMR checkpoint cell-local temporal partition targets another topology epoch"); + for (const auto& cell : accepted.temporal_partition.cells) + if (cell.level < 0 || cell.level >= p_->runtime->nlev()) + throw std::runtime_error( + "AMR checkpoint cell-local temporal partition targets an inactive level"); + } tagging_candidate = p_->runtime->prepare_checkpoint_tagging_state(accepted.tagging_hysteresis_state); } else { @@ -3445,6 +3471,13 @@ std::vector> AmrSystem::program_clock_manifest() const rows.push_back({"logical", identity, std::to_string(tick)}); return rows; } +std::vector> AmrSystem::program_temporal_partition_manifest() const { + if (p_->program_accepted_state_.empty()) + return {}; + const auto state = + runtime::program::deserialize_amr_program_accepted_state(p_->program_accepted_state_); + return runtime::program::BatchedCellTemporalPartition(state.temporal_partition).manifest(); +} std::vector> AmrSystem::program_flux_ledger_manifest() const { std::vector> rows; if (p_->program_accepted_state_.empty()) diff --git a/src/runtime/system/system.cpp b/src/runtime/system/system.cpp index ebd8ab29a..1caf597ac 100644 --- a/src/runtime/system/system.cpp +++ b/src/runtime/system/system.cpp @@ -59,11 +59,9 @@ void System::commit_step_transaction() { } std::map System::step_change_l2() const { if (!p_->external_step_transaction_) - throw std::runtime_error( - "System::step_change_l2 requires an active external step transaction"); + throw std::runtime_error("System::step_change_l2 requires an active external step transaction"); if (p_->polar_) - throw std::runtime_error( - "System::step_change_l2 does not yet define the polar cell measure"); + throw std::runtime_error("System::step_change_l2 does not yet define the polar cell measure"); const auto& previous = p_->external_step_transaction_->states; if (previous.size() != p_->sp.size()) throw std::runtime_error("System::step_change_l2 snapshot composition mismatch"); @@ -73,12 +71,11 @@ std::map System::step_change_l2() const { if (p_->geometry_mode_ == GeometryMode::CutCell) measure.inverse_volume_fraction = &p_->eb_inverse_volume_fraction_; } - const double cell_area = - static_cast(p_->geom.dx()) * static_cast(p_->geom.dy()); + const double cell_area = static_cast(p_->geom.dx()) * static_cast(p_->geom.dy()); std::map result; for (std::size_t block = 0; block < p_->sp.size(); ++block) { - const double sum_sq = static_cast( - pops::difference_sum_sq_all(p_->sp[block].U, previous[block], measure)); + const double sum_sq = + static_cast(pops::difference_sum_sq_all(p_->sp[block].U, previous[block], measure)); result.emplace(p_->sp[block].name, std::sqrt(cell_area * sum_sq)); } return result; @@ -134,8 +131,7 @@ void System::mark_bound() { throw std::runtime_error( "System::mark_bound: materialized block lacks its exact state route"); for (const auto& [name, plan] : p_->boundary_plans_) { - if (p_->eb_set_ && p_->geometry_mode_ != GeometryMode::None && - plan->has_component_boundaries()) + if (p_->eb_set_ && p_->geometry_mode_ != GeometryMode::None && plan->has_component_boundaries()) throw std::runtime_error( "System::mark_bound: embedded-boundary block '" + name + "' has a native boundary component without a geometry-aware provider"); @@ -148,11 +144,19 @@ void System::mark_bound() { throw std::runtime_error( "System::mark_bound: prepared boundary component count differs from block '" + name + "'"); - if (!same_periodicity(plan->periodicity(), p_->per_)) + const auto axis_periodicity = plan->axis_aligned_periodicity(); + if (axis_periodicity) { + if (!same_periodicity(*axis_periodicity, p_->per_)) + throw std::runtime_error( + "System::mark_bound: prepared boundary plan periodicity disagrees with the domain " + "topology for block '" + + name + "'"); + } else if (p_->per_.x || p_->per_.y) { throw std::runtime_error( - "System::mark_bound: prepared boundary plan periodicity disagrees with the domain " - "topology for block '" + + "System::mark_bound: an axis-permuted boundary plan cannot be combined with an " + "axis-periodic domain topology for block '" + name + "'"); + } (void)plan->has_boundary_linearization(); runtime::multiblock::BoundaryEvaluationPoint preparation_point; preparation_point.clock = plan->identity() + "::bound-runtime"; diff --git a/src/runtime/system/system_fields.cpp b/src/runtime/system/system_fields.cpp index 2db11fb65..e4d96021c 100644 --- a/src/runtime/system/system_fields.cpp +++ b/src/runtime/system/system_fields.cpp @@ -97,8 +97,16 @@ void System::set_density(const std::string& name, const std::vector& rho } POPS_EXPORT void System::set_block_conversion(const std::string& name, CellConvert prim_to_cons, - CellConvert cons_to_prim) { + CellRecovery cons_to_prim) { Impl::Species& s = p_->find(name); + const auto boundary = p_->boundary_plans_.find(name); + if (boundary != p_->boundary_plans_.end() && + boundary->second->requires_fixed_state_conversion()) { + if (!prim_to_cons) + throw std::runtime_error( + "System primitive fixed-state boundary requires the block-model conversion"); + boundary->second->prepare_fixed_state_conversion(prim_to_cons); + } s.prim_to_cons = std::move(prim_to_cons); s.cons_to_prim = std::move(cons_to_prim); } @@ -155,7 +163,16 @@ std::vector System::get_primitive_state(const std::string& name) { for (std::size_t k = 0; k < nn; ++k) { for (int c = 0; c < nc; ++c) cell_in[c] = cons[static_cast(c) * nn + k]; - s.cons_to_prim(cell_in.data(), cell_out.data()); + const RecoveryReport recovery = s.cons_to_prim(cell_in.data(), cell_out.data()); + if (!recovery.publication_permitted()) + throw std::runtime_error( + "System::get_primitive_state : variable recovery failed for block '" + name + + "' at local cell " + std::to_string(k) + " (status=" + + recovery_status_name(recovery.status) + ", cause=" + recovery_cause_name(recovery.cause) + + ", failing_component=" + std::to_string(recovery.failing_component) + + ", attempted_methods=" + std::to_string(recovery.attempted_methods) + + ", last_method=" + recovery_method_kind_name(recovery.last_method_kind) + + ", last_method_index=" + std::to_string(recovery.last_method) + ")"); for (int c = 0; c < nc; ++c) prim[static_cast(c) * nn + k] = cell_out[c]; } diff --git a/src/runtime/system/system_install.cpp b/src/runtime/system/system_install.cpp index 4ed735086..1cef0ab76 100644 --- a/src/runtime/system/system_install.cpp +++ b/src/runtime/system/system_install.cpp @@ -58,6 +58,10 @@ void System::add_block(const std::string& name, const ModelSpec& model, const st const bool imexrk = (time == "imexrk_ars222"); const bool imex = (time == "imex" || imexrk); // both go through the implicit source step const bool recon_prim = (recon == "primitive"); + if (newton_diagnostics) + throw std::runtime_error( + "System::add_block : newton_diagnostics=true is unavailable on the Program-only System " + "runtime because no typed implicit Program consumer publishes that report"); // Wave speed cache (opt-in): only engages for the HLL residual. Requesting it // elsewhere would be SILENTLY without effect -> explicit error (no silent ignore). The polar path has // its own factory (make_block_polar) without this cache. @@ -68,10 +72,10 @@ void System::add_block(const std::string& name, const ModelSpec& model, const st "speed cache only applies to the HLL flux ; received riemann='" + riemann + "')"); if (imex) - throw std::runtime_error("System::add_block : wave_speed_cache not supported with time='" + - time + - "' (the cached residual is not available to an implicit Program ; use time " - "'explicit'/'ssprk3'/'euler')"); + throw std::runtime_error( + "System::add_block : wave_speed_cache not supported with time='" + time + + "' (the cached residual is not available to an implicit Program ; use time " + "'explicit'/'ssprk3'/'euler')"); if (P->polar_) throw std::runtime_error( "System::add_block : wave_speed_cache not supported on the polar " @@ -135,7 +139,8 @@ void System::add_block(const std::string& name, const ModelSpec& model, const st std::function max_speed; std::function add_poisson_rhs; std::function src_freq, stab_dt; // optional step bounds (model traits) - CellConvert prim_to_cons, cons_to_prim; // pointwise model conversions (set/get_primitive_state) + CellConvert prim_to_cons; // pointwise model conversion (set_primitive_state) + CellRecovery cons_to_prim; // fallible prepared recovery (get_primitive_state) VariableSet cons_vs, prim_vs; detail::BuiltBlock bb; if (P->polar_) { @@ -160,12 +165,6 @@ void System::add_block(const std::string& name, const ModelSpec& model, const st P->ensure_aux_width(bb.aux_width); } else { const GridContext ctx = P->grid_ctx(name); - // Preserve the requested diagnostic carrier until the typed implicit Program primitive owns and - // writes it. The spatial closures never capture this state. - if (newton_diagnostics) { - auto rep = std::make_shared(); - P->diagnostics_.newton_reports[name] = rep; - } // Transport-axis seam (ADC-335): each per-transport TU (python/system_.cpp) runs the // SAME source/elliptic dispatch + make_block + makers as before (detail::build_block_for), but // instantiates ONLY its own transport's leaves -- so the combinatorial product splits across files @@ -255,11 +254,10 @@ void System::add_block(const std::string& name, const ModelSpec& model, const st // via Kokkos), without copy. install_block(name, ncomp, cons_vs, prim_vs, model.gamma, std::move(clo), std::move(max_speed), std::move(add_poisson_rhs), substeps, evolve, stride); - EffectiveBlockOptions block_options = - make_system_block_options(name, model, "native_model", limiter, riemann, recon, time, method, - substeps, evolve, stride, implicit_vars, implicit_roles, - newton, newton_diagnostics, positivity_floor, wave_speed_cache, - weno_epsilon); + EffectiveBlockOptions block_options = make_system_block_options( + name, model, "native_model", limiter, riemann, recon, time, method, substeps, evolve, stride, + implicit_vars, implicit_roles, newton, newton_diagnostics, positivity_floor, wave_speed_cache, + weno_epsilon); block_options.ncomp = ncomp; block_options.conservative_vars = cons_vs.names; block_options.primitive_vars = prim_vs.names; @@ -289,44 +287,6 @@ POPS_EXPORT GridContext System::grid_context(int block) { return p_->grid_ctx(p_->sp[static_cast(block)].name); } -namespace { -BCType prepared_bc_type(const std::string& token) { - if (token == "periodic") - return BCType::Periodic; - if (token == "foextrap") - return BCType::Foextrap; - if (token == "dirichlet") - return BCType::Dirichlet; - if (token == "external") - return BCType::External; - throw std::runtime_error("System::install_boundary_plan: unsupported face producer '" + token + - "'"); -} - -void set_prepared_face(BCRec& bc, int face, BCType type, Real value) { - switch (face) { - case 0: - bc.xlo = type; - bc.xlo_val = value; - return; - case 1: - bc.xhi = type; - bc.xhi_val = value; - return; - case 2: - bc.ylo = type; - bc.ylo_val = value; - return; - case 3: - bc.yhi = type; - bc.yhi_val = value; - return; - default: - throw std::runtime_error("System::install_boundary_plan: invalid face ordinal"); - } -} -} // namespace - POPS_EXPORT void System::install_block_state_route(const std::string& name, const std::string& state_identity) { Impl* P = p_.get(); @@ -346,50 +306,73 @@ POPS_EXPORT void System::install_block_state_route(const std::string& name, POPS_EXPORT void System::install_boundary_plan(const std::string& name, const std::string& identity, int required_depth, const std::vector& face_types, - const std::vector& face_values, int ncomp, + const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces, const std::string& state_identity, PreparedBoundaryReadDependencies read_dependencies) { - install_boundary_plan(name, identity, required_depth, face_types, face_values, ncomp, - omitted_interface_faces, state_identity, std::move(read_dependencies), {}); + install_boundary_plan(name, identity, required_depth, face_types, face_values, face_identities, + component_roles, omitted_interface_faces, state_identity, + std::move(read_dependencies), {}); } POPS_EXPORT void System::install_boundary_plan( const std::string& name, const std::string& identity, int required_depth, - const std::vector& face_types, const std::vector& face_values, int ncomp, + const std::vector& face_types, const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces, const std::string& state_identity, PreparedBoundaryReadDependencies read_dependencies, - std::vector periodic_identifications) { + std::vector periodic_identifications, + const std::vector& face_representations, + const std::vector& face_converter_identities, + const std::vector>& face_analytic_opcodes, + const std::vector>& face_analytic_literals, + const std::vector& face_analytic_clocks) { Impl* P = p_.get(); - require_assembling(P->lifecycle_, "install_boundary_plan"); - if (name.empty() || state_identity.empty()) - throw std::runtime_error( - "System::install_boundary_plan requires block and state-qualified identities"); - const auto state_route = P->block_state_identities_.find(name); - if (state_route == P->block_state_identities_.end() || state_route->second != state_identity) - throw std::runtime_error( - "System::install_boundary_plan state differs from the exact block state route"); - if (P->boundary_plans_.count(name) != 0) - throw std::runtime_error("System::install_boundary_plan duplicate block '" + name + "'"); - if (ncomp < 1 || face_types.size() != 4 || - face_values.size() != static_cast(4 * ncomp)) - throw std::runtime_error( - "System::install_boundary_plan requires four face types and ncomp*4 values"); - std::vector components(static_cast(ncomp)); - for (int comp = 0; comp < ncomp; ++comp) { - for (int face = 0; face < 4; ++face) { - set_prepared_face(components[static_cast(comp)], face, - prepared_bc_type(face_types[static_cast(face)]), - static_cast(face_values[static_cast(4 * comp + face)])); - } - } - auto plan = std::make_shared( - identity, required_depth, std::move(components), omitted_interface_faces, state_identity, - std::move(read_dependencies), std::move(periodic_identifications)); - for (const auto& [_, installed] : P->boundary_plans_) - if (installed->state_identity() == state_identity) - throw std::runtime_error("System::install_boundary_plan duplicate qualified state identity"); - P->boundary_plans_.emplace(name, std::move(plan)); + using BoundaryPlanMap = decltype(P->boundary_plans_); + using BoundaryPlanNode = typename BoundaryPlanMap::node_type; + BoundaryPlanNode prepared = analytic::collectively_prepare_exact_analytic_request( + "System::install_boundary_plan", + [&]() -> BoundaryPlanNode { + require_assembling(P->lifecycle_, "install_boundary_plan"); + if (name.empty() || state_identity.empty()) + throw std::runtime_error( + "System::install_boundary_plan requires block and state-qualified identities"); + const auto state_route = P->block_state_identities_.find(name); + if (state_route == P->block_state_identities_.end() || + state_route->second != state_identity) + throw std::runtime_error( + "System::install_boundary_plan state differs from the exact block state route"); + if (P->boundary_plans_.count(name) != 0) + throw std::runtime_error("System::install_boundary_plan duplicate block '" + name + "'"); + for (const auto& [_, installed] : P->boundary_plans_) + if (installed->state_identity() == state_identity) + throw std::runtime_error( + "System::install_boundary_plan duplicate qualified state identity"); + + auto hyperbolic = prepare_hyperbolic_boundary<2>( + face_types, face_values, face_identities, component_roles, + !periodic_identifications.empty(), face_representations, face_converter_identities, + face_analytic_opcodes, face_analytic_literals, face_analytic_clocks); + auto plan = std::make_shared( + identity, required_depth, std::move(hyperbolic), omitted_interface_faces, + state_identity, read_dependencies, periodic_identifications); + BoundaryPlanMap staged; + staged.emplace(name, std::move(plan)); + return staged.extract(staged.begin()); + }, + [&]() { + return detail::canonical_prepared_boundary_plan_request( + name, identity, required_depth, face_types, face_values, face_identities, + component_roles, omitted_interface_faces, state_identity, read_dependencies, + periodic_identifications, face_representations, face_converter_identities, + face_analytic_opcodes, face_analytic_literals, face_analytic_clocks); + }); + const auto published = P->boundary_plans_.insert(std::move(prepared)); + if (!published.inserted) + throw std::logic_error("System::install_boundary_plan lost its prepared publication slot"); } POPS_EXPORT void System::install_field_storage_route(const std::string& field_identity, @@ -429,6 +412,21 @@ POPS_EXPORT void System::install_ghost_boundary_component( found->second->install_ghost_component(std::move(spec), std::move(component)); } +POPS_EXPORT void System::install_boundary_flux_component( + const std::string& name, PreparedBoundaryComponentSpec spec, + std::shared_ptr component) { + Impl* P = p_.get(); + require_assembling(P->lifecycle_, "install_boundary_flux_component"); + if (P->eb_set_ && P->geometry_mode_ != GeometryMode::None) + throw std::runtime_error( + "System::install_boundary_flux_component: embedded-boundary transport has no " + "geometry-aware post-Riemann provider"); + const auto found = P->boundary_plans_.find(name); + if (found == P->boundary_plans_.end()) + throw std::runtime_error("System boundary flux requires an installed block boundary plan"); + found->second->install_flux_component(std::move(spec), std::move(component)); +} + POPS_EXPORT void System::install_field_boundary_residual_component( const std::string& name, PreparedBoundaryComponentSpec spec, std::shared_ptr component) { @@ -514,8 +512,7 @@ POPS_EXPORT void System::install_block(const std::string& name, int ncomp, if (stride < 1) throw std::runtime_error("System::install_block : stride >= 1"); Impl* P = p_.get(); - if (P->eb_set_ && !supports_geometry_mode(closures.supported_geometry_modes, - P->geometry_mode_)) + if (P->eb_set_ && !supports_geometry_mode(closures.supported_geometry_modes, P->geometry_mode_)) throw std::runtime_error( "System::install_block: block '" + name + "' has no numerical provider for the active embedded-boundary geometry"); @@ -523,9 +520,8 @@ POPS_EXPORT void System::install_block(const std::string& name, int ncomp, if (P->eb_set_ && P->geometry_mode_ != GeometryMode::None && boundary_plan != P->boundary_plans_.end() && boundary_plan->second->has_component_boundaries()) - throw std::runtime_error( - "System::install_block: embedded-boundary block '" + name + - "' has a native boundary component without a geometry-aware provider"); + throw std::runtime_error("System::install_block: embedded-boundary block '" + name + + "' has a native boundary component without a geometry-aware provider"); P->sp.push_back(Impl::Species{name, MultiFab(P->ba, P->dm, ncomp, 2), ncomp, substeps, evolve, stride, gamma, std::move(closures.rhs_into), std::move(max_speed), std::move(poisson_rhs)}); @@ -635,15 +631,16 @@ std::array System::dt_hotspot(const std::string& name) { return {static_cast(w), static_cast(i), static_cast(j)}; } -// Newton report (OPT-IN IMEX diagnostics) of the block. The carrier is written only by an installed -// typed implicit Program primitive; spatial block construction owns no implicit solve. +// Compatibility query for a typed implicit Program diagnostic carrier. The current Program-only +// System runtime rejects newton_diagnostics=true until a consumer actually publishes this carrier. System::SourceNewtonReport System::newton_report(const std::string& name) const { p_->index(name); // raises if unknown block const NewtonReport* rp = p_->diagnostics_.newton_report_ptr(name); if (rp == nullptr) throw std::runtime_error( - "System::newton_report : Newton diagnostics not enabled for block '" + name + - "' ; pass newton_diagnostics=True when installing the block"); + "System::newton_report : no typed implicit Program consumer published diagnostics for " + "block '" + + name + "'"); const NewtonReport& r = *rp; return SourceNewtonReport{r.enabled, r.converged, @@ -690,12 +687,14 @@ void System::add_native_block(const std::string& name, const std::string& so_pat opt.positivity_floor = positivity_floor; } -void System::add_external_riemann_block( - const std::string& name, const std::string& so_path, const std::string& brick_id, - const std::string& sha256, const std::string& limiter, const std::string& recon, - const std::string& time, double gamma, int substeps, bool evolve, int stride, - int expected_nvars, int expected_naux, const std::string& expected_model_identity, - double positivity_floor, double weno_epsilon) { +void System::add_external_riemann_block(const std::string& name, const std::string& so_path, + const std::string& brick_id, const std::string& sha256, + const std::string& limiter, const std::string& recon, + const std::string& time, double gamma, int substeps, + bool evolve, int stride, int expected_nvars, + int expected_naux, + const std::string& expected_model_identity, + double positivity_floor, double weno_epsilon) { require_assembling(p_->lifecycle_, "add_external_riemann_block"); auto library = std::make_shared( so_path, brick_id, sha256, expected_nvars, expected_naux, expected_model_identity); @@ -735,8 +734,7 @@ void System::set_poisson(const std::string& rhs, const std::string& solver, cons "System::set_poisson: polar geometry requires solver='polar'; solver substitution is " "forbidden"); if (!p_->polar_ && solver == "polar") - throw std::runtime_error( - "System::set_poisson: solver='polar' requires polar geometry"); + throw std::runtime_error("System::set_poisson: solver='polar' requires polar geometry"); using FieldSolver = field_solver::SystemFieldSolver; if (solver == "geometric_mg") { GeometricMgOptions mg_options; @@ -810,12 +808,12 @@ void System::set_field_solver_plan( const auto existing = p_->fields_.named_field_plans_.find(provider_slot); if (existing != p_->fields_.named_field_plans_.end()) throw std::runtime_error("System::set_field_solver_plan duplicate provider slot"); - const auto duplicate_output = std::find_if( - p_->fields_.named_field_plans_.begin(), p_->fields_.named_field_plans_.end(), - [&](const auto& configured) { - return configured.second.output_block == output_block && - configured.second.output_key == output_key; - }); + const auto duplicate_output = + std::find_if(p_->fields_.named_field_plans_.begin(), p_->fields_.named_field_plans_.end(), + [&](const auto& configured) { + return configured.second.output_block == output_block && + configured.second.output_key == output_key; + }); if (duplicate_output != p_->fields_.named_field_plans_.end()) throw std::runtime_error( "System::set_field_solver_plan output block/key already belongs to another qualified " @@ -1125,10 +1123,8 @@ struct AnalyticLevelSetPhysicalGhostKernel { } POPS_HD void operator()(int i, int j) const { - const bool physical_x = - !periodicity.x && (i < domain.lo[0] || i > domain.hi[0]); - const bool physical_y = - !periodicity.y && (j < domain.lo[1] || j > domain.hi[1]); + const bool physical_x = !periodicity.x && (i < domain.lo[0] || i > domain.hi[0]); + const bool physical_y = !periodicity.y && (j < domain.lo[1] || j > domain.hi[1]); if (!physical_x && !physical_y) return; @@ -1153,8 +1149,7 @@ struct AnalyticLevelSetMaskKernel { Array4 active_mask; POPS_HD void operator()(int i, int j) const { - active_mask(i, j, 0) = - level_set_values(i, j, 0) < Real(0) ? Real(1) : Real(0); + active_mask(i, j, 0) = level_set_values(i, j, 0) < Real(0) ? Real(1) : Real(0); } }; @@ -1171,8 +1166,7 @@ struct AnalyticInverseVolumeFractionKernel { } const detail::CutFraction fraction = detail::cut_fraction_from_samples( center, level_set_values(i - 1, j, 0), level_set_values(i + 1, j, 0), - level_set_values(i, j - 1, 0), level_set_values(i, j + 1, 0), dx, dy, - cut_theta_min); + level_set_values(i, j - 1, 0), level_set_values(i, j + 1, 0), dx, dy, cut_theta_min); const Real effective = fraction.kappa > kappa_min ? fraction.kappa : kappa_min; inverse_volume_fraction(i, j, 0) = Real(1) / effective; } @@ -1180,9 +1174,8 @@ struct AnalyticInverseVolumeFractionKernel { } // namespace void System::set_analytic_level_set(const std::vector& opcodes, - const std::vector& literals, - const std::string& mode, double kappa_min, - double face_open_eps, double cut_theta_min) { + const std::vector& literals, const std::string& mode, + double kappa_min, double face_open_eps, double cut_theta_min) { Impl* P = p_.get(); struct PreparedAnalyticLevelSet { GeometryMode geometry_mode = GeometryMode::None; @@ -1207,8 +1200,7 @@ void System::set_analytic_level_set(const std::vector& opcodes, "System::set_analytic_level_set : kappa_min / face_open_eps / " "cut_theta_min must be <= 1"); if (P->polar_) - throw std::runtime_error( - "System::set_analytic_level_set : Cartesian geometry required"); + throw std::runtime_error("System::set_analytic_level_set : Cartesian geometry required"); const GeometryMode geometry_mode = parse_geometry_mode(mode, "System::set_analytic_level_set"); if (geometry_mode != GeometryMode::None && P->ws_cache_block_) @@ -1223,9 +1215,9 @@ void System::set_analytic_level_set(const std::vector& opcodes, "cut-cell shared-interface provider"); for (const auto& block : P->sp) if (!supports_geometry_mode(block.supported_geometry_modes, geometry_mode)) - throw std::runtime_error( - "System::set_analytic_level_set: block '" + block.name + - "' has no numerical provider for embedded-boundary mode '" + mode + "'"); + throw std::runtime_error("System::set_analytic_level_set: block '" + block.name + + "' has no numerical provider for embedded-boundary mode '" + + mode + "'"); if (geometry_mode != GeometryMode::None) for (const auto& [name, plan] : P->boundary_plans_) if (plan->has_component_boundaries()) @@ -1242,8 +1234,7 @@ void System::set_analytic_level_set(const std::vector& opcodes, thresholds.face_open_eps = static_cast(face_open_eps); if (cut_theta_min > 0.0) thresholds.cut_theta_min = static_cast(cut_theta_min); - return PreparedAnalyticLevelSet{ - geometry_mode, thresholds, std::move(compiled.front())}; + return PreparedAnalyticLevelSet{geometry_mode, thresholds, std::move(compiled.front())}; }); const GeometryMode gmode = prepared.geometry_mode; @@ -1257,28 +1248,25 @@ void System::set_analytic_level_set(const std::vector& opcodes, // native halo topology. In particular, a periodic seam must copy the opposite valid value rather // than evaluate the expression at a fictitious coordinate outside the domain. for (int li = 0; li < staged_level_set_values.local_size(); ++li) - for_each_cell(staged_level_set_values.box(li), - AnalyticLevelSetValueKernel{ - view, P->geom, staged_level_set_values.fab(li).array()}); + for_each_cell( + staged_level_set_values.box(li), + AnalyticLevelSetValueKernel{view, P->geom, staged_level_set_values.fab(li).array()}); fill_boundary(staged_level_set_values, P->dom, P->per_); // Non-periodic physical ghosts have no halo source. They retain the analytic extension needed by // the centered cut-fraction stencil; mixed-periodic corners wrap only their periodic coordinate. for (int li = 0; li < staged_level_set_values.local_size(); ++li) for_each_cell(staged_level_set_values.fab(li).grown_box(), - AnalyticLevelSetPhysicalGhostKernel{ - view, P->geom, P->dom, P->per_, - staged_level_set_values.fab(li).array()}); + AnalyticLevelSetPhysicalGhostKernel{view, P->geom, P->dom, P->per_, + staged_level_set_values.fab(li).array()}); Real local_non_finite = Real(0); for (int li = 0; li < staged_level_set_values.local_size(); ++li) { const Box2D sampled = staged_level_set_values.fab(li).grown_box(); local_non_finite = std::max( local_non_finite, - for_each_cell_reduce_max( - sampled, - AnalyticLevelSetFiniteIndicator{ - staged_level_set_values.fab(li).const_array()})); + for_each_cell_reduce_max(sampled, AnalyticLevelSetFiniteIndicator{ + staged_level_set_values.fab(li).const_array()})); } if (all_reduce_max(static_cast(local_non_finite)) != 0.0) throw std::domain_error( @@ -1290,11 +1278,10 @@ void System::set_analytic_level_set(const std::vector& opcodes, const ConstArray4 phi = staged_level_set_values.fab(li).const_array(); for_each_cell(staged_mask.fab(li).grown_box(), AnalyticLevelSetMaskKernel{phi, staged_mask.fab(li).array()}); - for_each_cell( - staged_inverse_volume_fraction.box(li), - AnalyticInverseVolumeFractionKernel{ - phi, staged_inverse_volume_fraction.fab(li).array(), dx, dy, - staged_thresholds.kappa_min, staged_thresholds.cut_theta_min}); + for_each_cell(staged_inverse_volume_fraction.box(li), + AnalyticInverseVolumeFractionKernel{ + phi, staged_inverse_volume_fraction.fab(li).array(), dx, dy, + staged_thresholds.kappa_min, staged_thresholds.cut_theta_min}); } if (gmode != GeometryMode::None && sum(staged_mask, 0) <= Real(0)) throw std::domain_error( @@ -1313,8 +1300,8 @@ void System::set_analytic_level_set(const std::vector& opcodes, void System::set_disc_domain(double cx, double cy, double R, const std::string& mode, double kappa_min, double face_open_eps, double cut_theta_min) { - const std::vector opcodes{ - "x", "constant", "sub", "y", "constant", "sub", "hypot", "constant", "sub"}; + const std::vector opcodes{"x", "constant", "sub", "y", "constant", + "sub", "hypot", "constant", "sub"}; const std::vector literals{0.0, cx, 0.0, 0.0, cy, 0.0, 0.0, R, 0.0}; (void)analytic::collectively_prepare_analytic_request( "System::set_disc_domain", {{"mode", mode}}, @@ -1360,9 +1347,9 @@ void System::set_geometry_mode(const std::string& mode) { "shared-interface provider"); for (const auto& block : P->sp) if (!supports_geometry_mode(block.supported_geometry_modes, gmode)) - throw std::runtime_error( - "System::set_geometry_mode: block '" + block.name + - "' has no numerical provider for embedded-boundary mode '" + mode + "'"); + throw std::runtime_error("System::set_geometry_mode: block '" + block.name + + "' has no numerical provider for embedded-boundary mode '" + mode + + "'"); if (gmode != GeometryMode::None) for (const auto& [name, plan] : P->boundary_plans_) if (plan->has_component_boundaries()) @@ -1722,36 +1709,34 @@ void System::add_coupled_source(const CoupledSourceProgram& prog_desc, double fr } P->couplings.push_back([ins, outs, kconsts, n_in, n_const, n_terms]( Real dt, const std::vector& states) { - // MPI-safe: iteration over the LOCAL fabs of the first input block (or output if no - // input). local_size()==0 on a rank without a box -> empty loop, no-op (no hard-coded fab(0)). - const int sref = n_in > 0 ? ins[0].sidx : outs[0].sidx; - MultiFab& Uref = *states[static_cast(sref)]; - for (int li = 0; li < Uref.local_size(); ++li) { - CoupledSourceKernel kern; - kern.dt = dt; - kern.n_in = n_in; - kern.n_const = n_const; - kern.n_terms = n_terms; - for (int c = 0; c < n_in; ++c) { - kern.in[c] = - states[static_cast(ins[static_cast(c)].sidx)] - ->fab(li) - .array(); - kern.in_comp[c] = ins[static_cast(c)].comp; - } - for (int c = 0; c < n_const; ++c) - kern.consts[c] = kconsts[static_cast(c)]; - for (int t = 0; t < n_terms; ++t) { - kern.out[t] = - states[static_cast(outs[static_cast(t)].sidx)] - ->fab(li) - .array(); - kern.out_comp[t] = outs[static_cast(t)].comp; - kern.prog[t] = outs[static_cast(t)].prog; - } - for_each_cell(Uref.box(li), kern); // NAMED functor, device-clean additive forward-Euler + // MPI-safe: iteration over the LOCAL fabs of the first input block (or output if no + // input). local_size()==0 on a rank without a box -> empty loop, no-op (no hard-coded fab(0)). + const int sref = n_in > 0 ? ins[0].sidx : outs[0].sidx; + MultiFab& Uref = *states[static_cast(sref)]; + for (int li = 0; li < Uref.local_size(); ++li) { + CoupledSourceKernel kern; + kern.dt = dt; + kern.n_in = n_in; + kern.n_const = n_const; + kern.n_terms = n_terms; + for (int c = 0; c < n_in; ++c) { + kern.in[c] = states[static_cast(ins[static_cast(c)].sidx)] + ->fab(li) + .array(); + kern.in_comp[c] = ins[static_cast(c)].comp; } - }); + for (int c = 0; c < n_const; ++c) + kern.consts[c] = kconsts[static_cast(c)]; + for (int t = 0; t < n_terms; ++t) { + kern.out[t] = states[static_cast(outs[static_cast(t)].sidx)] + ->fab(li) + .array(); + kern.out_comp[t] = outs[static_cast(t)].comp; + kern.prog[t] = outs[static_cast(t)].prog; + } + for_each_cell(Uref.box(li), kern); // NAMED functor, device-clean additive forward-Euler + } + }); // Inspect metadata (ADC-595): a raw add_coupled_source declares NO conservation contract, so it // registers an "unchecked" view (empty ConservationContract) carrying the label and the frequency // bound. add_coupling_operator overwrites this behavior by pushing the DECLARED contract instead. diff --git a/src/runtime/system/system_program.cpp b/src/runtime/system/system_program.cpp index 648881949..4f322b507 100644 --- a/src/runtime/system/system_program.cpp +++ b/src/runtime/system/system_program.cpp @@ -227,16 +227,12 @@ void System::block_boundary_residual_into_at( if (!block_has_boundary_linearization(b)) throw std::runtime_error("System block has no executable boundary residual/JVP pair"); auto& block = p_->sp[static_cast(b)]; - if (block.boundary_session) { - if (!block.boundary_residual_at_point_prepared) - throw std::runtime_error("System block lacks its prepared boundary residual closure"); - block.boundary_residual_at_point_prepared(point, U, C, *block.boundary_session); - return; - } - auto& closure = block.boundary_residual_at_point; - if (!closure) - throw std::runtime_error("System block lacks its boundary residual closure"); - closure(point, U, C); + if (!block.boundary_session) + throw std::runtime_error( + "System boundary residual requires its persistent prepared boundary session"); + if (!block.boundary_residual_at_point_prepared) + throw std::runtime_error("System block lacks its prepared boundary residual closure"); + block.boundary_residual_at_point_prepared(point, U, C, *block.boundary_session); } void System::block_boundary_residual_into_at( @@ -262,16 +258,12 @@ void System::block_boundary_jvp_into_at(const runtime::multiblock::BoundaryEvalu if (!block_has_boundary_linearization(b)) throw std::runtime_error("System block has no executable boundary residual/JVP pair"); auto& block = p_->sp[static_cast(b)]; - if (block.boundary_session) { - if (!block.boundary_jvp_at_point_prepared) - throw std::runtime_error("System block lacks its prepared boundary JVP closure"); - block.boundary_jvp_at_point_prepared(point, U, V, J, *block.boundary_session); - return; - } - auto& closure = block.boundary_jvp_at_point; - if (!closure) - throw std::runtime_error("System block lacks its boundary JVP closure"); - closure(point, U, V, J); + if (!block.boundary_session) + throw std::runtime_error( + "System boundary JVP requires its persistent prepared boundary session"); + if (!block.boundary_jvp_at_point_prepared) + throw std::runtime_error("System block lacks its prepared boundary JVP closure"); + block.boundary_jvp_at_point_prepared(point, U, V, J, *block.boundary_session); } void System::block_boundary_jvp_into_at(const runtime::multiblock::BoundaryEvaluationPoint& point, int b, MultiFab& U, const MultiFab& V, MultiFab& J, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c28cb399d..f267417e8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -556,6 +556,8 @@ set(POPS_CPP_STANDARD_TESTS test_roe_flux test_riemann_capabilities test_newton_robustness + test_variable_recovery_chain + test_prepared_numerics_gate test_elliptic_interface test_field_nullspace test_field_context @@ -692,6 +694,9 @@ pops_add_gtest_suite( pops_test_source(_src test_program_reflux_ledger) pops_add_gtest_suite(NAME test_program_reflux_ledger SOURCES "${_src}" EXTRA_LIBS ${CMAKE_DL_LIBS} pops_runtime_amr) +pops_test_source(_src test_temporal_partition_restart) +pops_add_gtest_suite(NAME test_temporal_partition_restart SOURCES "${_src}" EXTRA_LIBS ${CMAKE_DL_LIBS} pops_runtime_amr) + pops_test_source(_src test_amr_transfer_properties) pops_add_gtest_suite(NAME test_amr_transfer_properties SOURCES "${_src}" EXTRA_LIBS ${CMAKE_DL_LIBS} pops_runtime_amr) @@ -781,6 +786,7 @@ if(POPS_HAS_MPI) set(POPS_MPI_RANKS_test_mpi_composite_fac 1 2 4) set(POPS_MPI_RANKS_test_amr_regrid_mpi_parity 1 2 4) set(POPS_MPI_RANKS_test_mpi_amr_dynamic_active_depth 1 2 4) + set(POPS_MPI_RANKS_test_mpi_amr_prepared_boundary_cf 1 2 4) set(POPS_MPI_RANKS_test_mpi_system_solve_fields 1 2 4) set(POPS_MPI_RANKS_test_mpi_system_fft 1 2 4) set(POPS_MPI_RANKS_test_mpi_system_analytic_level_set 2) @@ -823,6 +829,7 @@ if(POPS_HAS_MPI) test_mpi_composite_fac test_amr_regrid_mpi_parity test_mpi_amr_dynamic_active_depth + test_mpi_amr_prepared_boundary_cf test_mpi_system_solve_fields test_mpi_system_fft test_mpi_system_analytic_level_set diff --git a/tests/cpp/build_durations.json b/tests/cpp/build_durations.json index 63f1b081b..1877e619d 100644 --- a/tests/cpp/build_durations.json +++ b/tests/cpp/build_durations.json @@ -6,7 +6,8 @@ "test_amr_program_diffusion", "test_amr_program_positivity_floor", "test_flux_failure_loader_transaction", - "test_interface_flux_fragment_ledger" + "test_interface_flux_fragment_ledger", + "test_temporal_partition_restart" ], "estimate_policy": "new unmeasured targets use a conservative analogous-target estimate, falling back to the catalog median until the next cold-CI timing refresh", "measured_refresh": { @@ -16,7 +17,7 @@ "refresh_source_run": "30190778708", "source_job": "87152034744", "source_run": "29352485297", - "target_count": 187, + "target_count": 188, "unit_seconds": "modeled shard wall time: measured serial-pool TU or parallel-share floor" }, "test_adaptive_multirate": 2.0, @@ -161,6 +162,7 @@ "test_polar_transport_mms": 2.0, "test_positivity_floor": 2.0, "test_prepared_boundary_plan": 2.0, + "test_prepared_numerics_gate": 2.0, "test_primitive_recon": 2.0, "test_pure_field_algebra_extreme_dot": 2.0, "test_profiler": 2.0, @@ -191,6 +193,7 @@ "test_step_attempt_rejected_header_only": 2.0, "test_structured_solver_diagnostics": 2.0, "test_sync_residence": 2.0, + "test_temporal_partition_restart": 15.0, "test_system_abstraction": 2.0, "test_system_coupler": 2.0, "test_system_hardening": 2.0, @@ -199,6 +202,7 @@ "test_two_species_minimal": 2.0, "test_user_time_integrator": 2.0, "test_variable_epsilon": 2.0, + "test_variable_recovery_chain": 2.0, "test_variable_role": 2.0, "test_variable_user_role": 2.0, "test_wave_speed_cache_engagement": 2.0, diff --git a/tests/cpp/integration/amr/test_amr_system_contract.cpp b/tests/cpp/integration/amr/test_amr_system_contract.cpp index a90f6eda1..c873a692f 100644 --- a/tests/cpp/integration/amr/test_amr_system_contract.cpp +++ b/tests/cpp/integration/amr/test_amr_system_contract.cpp @@ -10,6 +10,7 @@ #include #include "explicit_amr_program.hpp" +#include #include #include #include @@ -79,6 +80,31 @@ static void install_regrid_state_authorities(AmrSystem& system, } } +TEST(test_amr_system_contract, RefusesMappedPeriodicityBeforeAmrFillPatchConstruction) { +#if defined(POPS_HAS_KOKKOS) + Kokkos::ScopeGuard guard; +#endif + AmrSystemConfig cfg; + cfg.n = 8; + cfg.L = 1.0; + cfg.regrid_every = 0; + cfg.periodicity = {false, false}; + AmrSystem system(cfg); + const std::string state_identity = "case::block::tracer::state::U"; + system.install_block_state_route("tracer", state_identity); + const PeriodicIdentification2D xlo_to_yhi{0, 3, std::array{{1, 0}}, + std::array{{1, 1}}}; + + EXPECT_THROW( + system.install_boundary_plan( + "tracer", "case::block::tracer::boundary", 1, + {"periodic", "foextrap", "foextrap", "periodic"}, std::vector(4, 0.0), + {"case::block::tracer::xlo", "case::block::tracer::xhi", "case::block::tracer::ylo", + "case::block::tracer::yhi"}, + {"Scalar"}, {}, state_identity, PreparedBoundaryReadDependencies{}, {xlo_to_yhi}), + std::runtime_error); +} + TEST(test_amr_system_contract, Runs) { #if defined(POPS_HAS_KOKKOS) Kokkos::ScopeGuard guard; @@ -391,6 +417,62 @@ TEST(test_amr_system_contract, Runs) { } } +TEST(test_amr_system_contract, PrimitiveFixedStateUsesTheConcreteAmrBlockModelConversion) { +#if defined(POPS_HAS_KOKKOS) + Kokkos::ScopeGuard guard; +#endif + AmrSystemConfig cfg; + cfg.n = 4; + cfg.L = 1.0; + cfg.regrid_every = 0; + cfg.periodicity = {false, false}; + AmrSystem system(cfg); + const std::string state_identity = "case::block::fluid::state::U"; + system.install_block_state_route("fluid", state_identity); + std::vector face_values; + for (const double primitive : {2.0, 3.0, -1.0}) + face_values.insert(face_values.end(), {0.0, primitive, 0.0, 0.0}); + system.install_boundary_plan("fluid", "case::block::fluid::boundary", 2, + {"foextrap", "dirichlet", "foextrap", "foextrap"}, face_values, + {"case::block::fluid::xlo", "case::block::fluid::xhi", + "case::block::fluid::ylo", "case::block::fluid::yhi"}, + {"Density", "MomentumX", "MomentumY"}, {}, state_identity, {}, {}, + {"conservative", "primitive", "conservative", "conservative"}, + {"", "case::block::fluid::model-p2c", "", ""}); + system.add_block("fluid", magnetic_fluid_spec(), "minmod", "rusanov", "conservative", "explicit", + 1); + (void)system.mass("fluid"); + + AmrRuntime* runtime = system.engine(); + ASSERT_NE(runtime, nullptr); + MultiFab& state = runtime->level_state(0, 0); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { + for (int component = 0; component < 3; ++component) + values(i, j, component) = Real(1); + }); + } + device_fence(); + MultiFab rhs = runtime->level_scalar_field(0, state.ncomp(), 0); + runtime->level_rhs_into(0, 0, state, rhs); + device_fence(); + state.sync_host(); + + const Box2D domain = runtime->level_geom(0).domain; + bool observed = false; + for (int local = 0; local < state.local_size(); ++local) { + const Fab2D& values = state.fab(local); + if (!values.grown_box().contains(domain.hi[0] + 1, 2)) + continue; + observed = true; + EXPECT_EQ(values(domain.hi[0] + 1, 2, 0), Real(3)); + EXPECT_EQ(values(domain.hi[0] + 1, 2, 1), Real(11)); + EXPECT_EQ(values(domain.hi[0] + 1, 2, 2), Real(-5)); + } + EXPECT_TRUE(observed); +} + TEST(test_amr_system_contract, VariableDtStrideUsesOneExactPublicWindow) { #if defined(POPS_HAS_KOKKOS) Kokkos::ScopeGuard guard; diff --git a/tests/cpp/integration/amr/test_amr_transfer_properties.cpp b/tests/cpp/integration/amr/test_amr_transfer_properties.cpp index 1c3bbda1f..55c326a60 100644 --- a/tests/cpp/integration/amr/test_amr_transfer_properties.cpp +++ b/tests/cpp/integration/amr/test_amr_transfer_properties.cpp @@ -102,8 +102,13 @@ AmrRuntime bootstrap_runtime(int cells = 8, bool install_prepared_boundary = fal const std::string state_identity = block.state_identity; block.boundary_plan = std::make_shared( "case::bootstrap::transport::boundary", 1, - std::vector(static_cast(block.ncomp), BCRec{}), std::vector{}, - state_identity); + prepare_hyperbolic_boundary<2>( + {"periodic", "periodic", "periodic", "periodic"}, + std::vector(static_cast(4 * block.ncomp), 0.0), + {"case::bootstrap::xlo", "case::bootstrap::xhi", "case::bootstrap::ylo", + "case::bootstrap::yhi"}, + std::vector(static_cast(block.ncomp), "Custom")), + std::vector{}, state_identity); const PreparedBoundaryPlan* const expected_plan = block.boundary_plan.get(); block.boundary_field_registry = std::make_shared(); block.level_rhs_core_at_point_prepared = @@ -582,3 +587,78 @@ TEST(test_amr_transfer_properties, BootstrapMaterializesPreparedBoundarySessionA coarse_rhs.fab(0).const_array()(coarse_rhs.box(0).lo[0], coarse_rhs.box(0).lo[1], 0), kPreparedBoundarySentinel); } + +TEST(test_amr_transfer_properties, RuntimePreparedSlipWallFillsDeepPhysicalGhosts) { + const Box2D domain = Box2D::from_extents(4, 4); + const BoxArray boxes(std::vector{domain}); + const DistributionMapping distribution(boxes.size(), n_ranks()); + const Geometry geometry{domain, Real(0), Real(1), Real(0), Real(1)}; + const auto load_balance = test::prepare_test_space_filling_curve_load_balance(); + AmrHierarchyLayout hierarchy{{boxes}, {distribution}, {Real(0.25)}, {Real(0.25)}, + {}, load_balance}; + + MultiFab state(boxes, distribution, 5, 2); + state.set_val(Real(-99)); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { + values(i, j, 0) = Real(1); + values(i, j, 1) = Real(2); + values(i, j, 2) = Real(5); + values(i, j, 3) = Real(3); + values(i, j, 4) = Real(4); + }); + } + device_fence(); + auto levels = std::make_shared>(); + levels->push_back(AmrLevelMP{std::move(state), nullptr, Real(0.25), Real(0.25)}); + + AmrRuntimeBlock block; + block.name = "fluid"; + block.state_identity = "case::amr::fluid::state::U"; + block.ncomp = 5; + block.levels = std::move(levels); + block.boundary_plan = std::make_shared( + "case::amr::fluid::boundary", 2, + prepare_hyperbolic_boundary<2>({"slip_wall", "slip_wall", "slip_wall", "slip_wall"}, + std::vector(20, 0.0), + {"case::amr::fluid::xlo", "case::amr::fluid::xhi", + "case::amr::fluid::ylo", "case::amr::fluid::yhi"}, + {"Density", "MomentumX", "MomentumX", "MomentumY", "AxialZ"}), + std::vector{}, block.state_identity); + block.boundary_field_registry = std::make_shared(); + block.level_rhs_core_at_point_prepared = + [](const runtime::multiblock::BoundaryEvaluationPoint& point, MultiFab& U, const MultiFab&, + const Geometry&, MultiFab& R, const PreparedGridBoundarySession& boundary) { + boundary.fill_same_level_and_physical(U, point); + R.set_val(Real(0)); + }; + block.level_boundary_residual_at_point_prepared = + [](const runtime::multiblock::BoundaryEvaluationPoint&, MultiFab&, const MultiFab&, + const Geometry&, MultiFab&, const PreparedGridBoundarySession&) {}; + + BCRec poisson_boundary; + poisson_boundary.xlo = poisson_boundary.xhi = BCType::Foextrap; + poisson_boundary.ylo = poisson_boundary.yhi = BCType::Foextrap; + std::vector blocks; + blocks.push_back(std::move(block)); + AmrRuntime runtime(geometry, std::move(hierarchy), poisson_boundary, std::move(blocks), + Periodicity{false, false}, true); + runtime.install_boundary_storage_routes({}); + + MultiFab& live = runtime.level_state(0, 0); + MultiFab rhs(live.box_array(), live.dmap(), live.ncomp(), 0); + const runtime::multiblock::BoundaryEvaluationPoint point{"clock.amr-slip", 0, 0, 0, 0, + amr::Rational(0, 1), 0.1, 0.0}; + EXPECT_NO_THROW(runtime.level_rhs_into_at(0, 0, point, live, rhs)); + device_fence(); + + if (live.local_size() > 0) { + const ConstArray4 values = live.fab(0).const_array(); + EXPECT_EQ(values(-2, 2, 1), Real(-2)); + EXPECT_EQ(values(-2, 2, 2), Real(-5)); + EXPECT_EQ(values(-2, 2, 4), Real(-4)); + EXPECT_EQ(values(2, -2, 3), Real(-3)); + EXPECT_EQ(values(2, -2, 4), Real(-4)); + } +} diff --git a/tests/cpp/integration/amr/test_temporal_partition_restart.cpp b/tests/cpp/integration/amr/test_temporal_partition_restart.cpp new file mode 100644 index 000000000..a96a6893d --- /dev/null +++ b/tests/cpp/integration/amr/test_temporal_partition_restart.cpp @@ -0,0 +1,158 @@ +#include + +#include "explicit_amr_program.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include + +#if defined(POPS_HAS_KOKKOS) +#include +#endif + +using namespace pops; +using namespace pops::runtime::program; + +namespace { + +CellTemporalPartitionAcceptedState cell_local_state(std::uint64_t topology_epoch = 7) { + CellTemporalPartitionAcceptedState state; + state.kind = TemporalPartitionKind::CellLocal; + state.provider_identity = "test.temporal-partition.batched-cells@1"; + state.topology_epoch = topology_epoch; + state.synchronization_tick = 8; + state.tick_denominator = 16; + state.cells = {{0, 10, 0, 8}, {0, 11, 1, 8}, {1, 20, 2, 8}}; + return state; +} + +ModelSpec exb_spec() { + ModelSpec spec; + spec.transport = "exb"; + spec.source = "none"; + spec.elliptic = "charge"; + return spec; +} + +} // namespace + +TEST(test_temporal_partition_restart, batched_attempt_commit_and_rollback_are_exact) { + const CellTemporalPartitionAcceptedState accepted = cell_local_state(); + BatchedCellTemporalPartition partition(accepted); + + partition.begin_attempt(16); + partition.advance_batch(0, {0}, 12); + partition.advance_batch(0, {0}, 16); + EXPECT_THROW(partition.require_barrier("field solve"), std::logic_error); + partition.rollback(); + EXPECT_EQ(partition.checkpoint(), accepted); + + partition.begin_attempt(16); + partition.advance_batch(0, {0}, 16); + partition.advance_batch(1, {1}, 16); + partition.advance_batch(2, {2}, 16); + EXPECT_NO_THROW(partition.require_barrier("output")); + partition.commit(); + + const CellTemporalPartitionAcceptedState committed = partition.checkpoint(); + EXPECT_EQ(committed.synchronization_tick, 16); + for (const CellTemporalPartitionRecord& cell : committed.cells) + EXPECT_EQ(cell.accepted_tick, 16); + const auto manifest = partition.manifest(); + ASSERT_EQ(manifest.size(), 4u); + EXPECT_EQ(manifest[0][1], "cell_local"); + EXPECT_EQ(manifest[0][6], "3"); + EXPECT_EQ(manifest[1], (std::vector{"rung", "0", "1"})); + EXPECT_EQ(manifest[2], (std::vector{"rung", "1", "1"})); + EXPECT_EQ(manifest[3], (std::vector{"rung", "2", "1"})); +} + +TEST(test_temporal_partition_restart, malformed_state_and_batches_fail_before_mutation) { + const CellTemporalPartitionAcceptedState accepted = cell_local_state(); + BatchedCellTemporalPartition partition(accepted); + + CellTemporalPartitionAcceptedState unsynchronized = accepted; + unsynchronized.cells[1].accepted_tick = 6; + EXPECT_THROW(partition.restore(unsynchronized), std::invalid_argument); + EXPECT_EQ(partition.checkpoint(), accepted); + + partition.begin_attempt(16); + EXPECT_THROW(partition.advance_batch(0, {0, 0}, 12), std::invalid_argument); + EXPECT_THROW(partition.advance_batch(0, {1}, 12), std::invalid_argument); + EXPECT_THROW(partition.advance_batch(2, {2}, 10), std::invalid_argument); + partition.rollback(); + EXPECT_EQ(partition.checkpoint(), accepted); + + EXPECT_THROW(partition.require_global_execution_route(), std::logic_error); + EXPECT_NO_THROW(BatchedCellTemporalPartition().require_global_execution_route()); +} + +TEST(test_temporal_partition_restart, accepted_image_round_trips_canonically) { + AmrProgramAcceptedState accepted; + accepted.temporal_partition = cell_local_state(); + + const std::vector encoded = serialize_amr_program_accepted_state(accepted); + const AmrProgramAcceptedState decoded = deserialize_amr_program_accepted_state(encoded); + EXPECT_EQ(decoded.temporal_partition, accepted.temporal_partition); + EXPECT_EQ(serialize_amr_program_accepted_state(decoded), encoded); + + CellTemporalPartitionAcceptedState duplicate = accepted.temporal_partition; + duplicate.cells[1].cell = duplicate.cells[0].cell; + accepted.temporal_partition = duplicate; + EXPECT_THROW(serialize_amr_program_accepted_state(accepted), std::invalid_argument); +} + +TEST(test_temporal_partition_restart, + strict_amr_restore_consumes_manifest_and_refuses_global_step_bypass) { +#if defined(POPS_HAS_KOKKOS) + Kokkos::ScopeGuard guard; +#endif + AmrSystemConfig config; + config.n = 4; + config.L = 1.0; + config.regrid_every = 0; + config.periodicity = {true, true}; + + AmrSystem system(config); + system.add_block("tracer", exb_spec(), "none", "rusanov", "conservative", "explicit", 1); + test::install_forward_euler_program(system); + system.step(0.01); + + AmrProgramAcceptedState accepted = + deserialize_amr_program_accepted_state(system.program_accepted_state()); + accepted.temporal_partition = cell_local_state(system.engine()->topology_epoch()); + // This fixture materializes one active level. Keep the three canonical cells and their distinct + // rungs, but qualify every record with that real hierarchy instead of an inactive synthetic level. + accepted.temporal_partition.cells.back().level = 0; + const std::vector cell_local_image = serialize_amr_program_accepted_state(accepted); + system.restore_checkpoint_accepted_state(cell_local_image); + + const auto manifest = system.program_temporal_partition_manifest(); + ASSERT_EQ(manifest.size(), 4u); + EXPECT_EQ(manifest[0][1], "cell_local"); + EXPECT_EQ(manifest[0][2], "test.temporal-partition.batched-cells@1"); + + const double time_before = system.time(); + const int step_before = system.macro_step(); + const std::vector bytes_before = system.program_accepted_state(); + EXPECT_THROW(system.step(0.01), std::logic_error) + << "an authenticated cell-local schedule cannot degrade to the global AMR driver"; + EXPECT_DOUBLE_EQ(system.time(), time_before); + EXPECT_EQ(system.macro_step(), step_before); + EXPECT_EQ(system.program_accepted_state(), bytes_before); + EXPECT_EQ(system.program_temporal_partition_manifest(), manifest); + + AmrProgramAcceptedState wrong_topology = accepted; + ++wrong_topology.temporal_partition.topology_epoch; + EXPECT_THROW(system.restore_checkpoint_accepted_state( + serialize_amr_program_accepted_state(wrong_topology)), + std::runtime_error); + EXPECT_EQ(system.program_accepted_state(), bytes_before) + << "rejected restore must not replace the accepted image"; +} diff --git a/tests/cpp/integration/mpi/test_mpi_amr_prepared_boundary_cf.cpp b/tests/cpp/integration/mpi/test_mpi_amr_prepared_boundary_cf.cpp new file mode 100644 index 000000000..5dce88dc0 --- /dev/null +++ b/tests/cpp/integration/mpi/test_mpi_amr_prepared_boundary_cf.cpp @@ -0,0 +1,271 @@ +// Distributed qualification of the sole prepared transport-boundary authority at a moving AMR +// hierarchy. +// +// A real regrid first removes and then recreates the fine level. The recreated patch remains +// strictly inside the physical domain, so every uncovered fine ghost is a coarse/fine interface +// ghost, never a physical-boundary ghost. The persistent PreparedGridBoundarySession must execute +// the conservative coarse/fine producer before same-level/MPI and physical-face production. A +// large fixed physical value makes any accidental patch-edge-as-domain-edge routing immediately +// visible. + +#include + +#include "amr_tagging_test_authority.hpp" +#include "amr_transfer_test_authority.hpp" +#include "gtest_compat.hpp" +#include "load_balance_test_authority.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#if defined(POPS_HAS_KOKKOS) +#include +#endif + +using namespace pops; + +namespace { + +constexpr Real kCoarseValue = Real(7.25); +constexpr Real kFineValue = Real(3.0); +constexpr Real kPhysicalValue = Real(40.0); +constexpr Real kUntouchedGhost = Real(-901.0); +constexpr Real kExpectedPhysicalGhost = Real(2) * kPhysicalValue - kCoarseValue; + +bool covered_by(const BoxArray& boxes, int i, int j) { + return std::any_of(boxes.boxes().begin(), boxes.boxes().end(), + [=](const Box2D& box) { return box.contains(i, j); }); +} + +POPS_HD Real native_exp(Real value) { +#if defined(POPS_HAS_KOKKOS) + return Kokkos::exp(value); +#else + return std::exp(value); +#endif +} + +void seed_centered_refinement_marker(MultiFab& state, int resolution) { + state.set_val(Real(1)); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + const Box2D valid = state.box(local); + for_each_cell(valid, [=] POPS_HD(int i, int j) { + const Real x = (Real(i) + Real(0.5)) / Real(resolution); + const Real y = (Real(j) + Real(0.5)) / Real(resolution); + const Real r2 = (x - Real(0.5)) * (x - Real(0.5)) + (y - Real(0.5)) * (y - Real(0.5)); + values(i, j, 0) = Real(1) + Real(0.8) * native_exp(-r2 / Real(0.0064)); + }); + } + device_fence(); +} + +int run_prepared_boundary_cf_regrid(int me, int np) { + constexpr int n = 16; + const Geometry geometry{Box2D::from_extents(n, n), Real(0), Real(1), Real(0), Real(1)}; + const BoxArray coarse_boxes = BoxArray::from_domain(geometry.domain, n / 2); + const Box2D initial_fine_patch = Box2D{{4, 4}, {11, 11}}.refine(2); + + auto levels = std::make_shared>(); + levels->push_back( + AmrLevelMP{MultiFab(coarse_boxes, DistributionMapping(coarse_boxes.size(), n_ranks()), 1, 1), + nullptr, geometry.dx(), geometry.dy()}); + levels->push_back( + AmrLevelMP{MultiFab(BoxArray({initial_fine_patch}), DistributionMapping({0}), 1, 1), nullptr, + geometry.dx() / Real(2), geometry.dy() / Real(2)}); + + MultiFab& coarse_seed = levels->front().U; + seed_centered_refinement_marker(coarse_seed, n); + levels->back().U.set_val(Real(1)); + + const auto load_balance = test::prepare_test_space_filling_curve_load_balance(); + AmrHierarchyLayout hierarchy = AmrHierarchyLayout::from_levels(*levels, load_balance); + + const std::string state_identity = "test://adc749/mpi-amr/block/tracer/state/U"; + AmrRuntimeBlock block; + block.name = "tracer"; + block.state_identity = state_identity; + block.ncomp = 1; + block.levels = levels; + block.add_elliptic_rhs = [](const MultiFab&, MultiFab&) {}; + block.max_speed = [](const MultiFab&, const MultiFab&) { return Real(0); }; + block.boundary_plan = std::make_shared( + "test://adc749/mpi-amr/block/tracer/boundary", 1, + prepare_hyperbolic_boundary<2>({"dirichlet", "dirichlet", "dirichlet", "dirichlet"}, + std::vector(4, static_cast(kPhysicalValue)), + {"test://adc749/mpi-amr/xlo", "test://adc749/mpi-amr/xhi", + "test://adc749/mpi-amr/ylo", "test://adc749/mpi-amr/yhi"}, + {"Scalar"}), + std::vector{}, state_identity); + block.boundary_field_registry = std::make_shared(); + block.level_rhs_core_at_point_prepared = + [](const runtime::multiblock::BoundaryEvaluationPoint& point, MultiFab& state, + const MultiFab&, const Geometry&, MultiFab& residual, + const PreparedGridBoundarySession& boundary) { + boundary.fill(state, point); + residual.set_val(Real(0)); + }; + block.level_boundary_residual_at_point_prepared = + [](const runtime::multiblock::BoundaryEvaluationPoint&, MultiFab&, const MultiFab&, + const Geometry&, MultiFab&, const PreparedGridBoundarySession&) {}; + block.level_rhs_at_point = [](const runtime::multiblock::BoundaryEvaluationPoint&, MultiFab&, + const MultiFab&, const Geometry&, MultiFab&) { + throw std::runtime_error("legacy AMR boundary fallback was selected"); + }; + + BCRec poisson_boundary; + poisson_boundary.xlo = poisson_boundary.xhi = BCType::Foextrap; + poisson_boundary.ylo = poisson_boundary.yhi = BCType::Foextrap; + std::vector blocks; + blocks.push_back(std::move(block)); + AmrRuntime runtime(geometry, std::move(hierarchy), poisson_boundary, std::move(blocks), + Periodicity{false, false}, /*replicated_coarse=*/false); + test::install_second_order_amr_transfer_authorities(runtime, 1); + runtime.set_parent_child_temporal_relations({amr::ParentChildClockRelation( + 0, 1, amr::Rational(2, 1), amr::RemainderPolicy::IntegralOnly)}); + runtime.install_boundary_storage_routes({}); + + // Exercise both topology transitions. Boundary sessions and coarse/fine workspaces must be + // destroyed with the removed level and rematerialized for the new distributed fine layout. + test::install_prepared_threshold_decisions( + runtime, {{0, 0, Real(1e9), test::PreparedThresholdRelation::Above}}, + {{0, 0, Real(1e9), test::PreparedThresholdRelation::Below}}, "test::adc749::remove-fine@1"); + runtime.regrid(); + const bool removed = runtime.nlev() == 1; + + // Fine-to-coarse removal legitimately averages the former fine state into the parent. Re-seed + // the coarse tagging field so this fixture requests a second, independent topology transition. + seed_centered_refinement_marker(runtime.level_state(0, 0), n); + test::install_prepared_threshold_decisions( + runtime, {{0, 0, Real(1.05), test::PreparedThresholdRelation::Above}}, + {{0, 0, Real(1.05), test::PreparedThresholdRelation::Below}}, "test::adc749::regrow-fine@1"); + runtime.regrid(); + const bool regrown = runtime.nlev() == 2 && runtime.n_patches() > 0; + + // Do not enter a boundary fill collectively unless every rank published both topology + // transitions. A broken regrid consensus must fail this fixture, never strand only a subset of + // ranks inside the MPI halo exchange exercised below. + const bool topology_ready = all_reduce_max(removed && regrown ? 0L : 1L) == 0L; + long local_failures = topology_ready ? 0L : 1L; + long local_cf_ghosts = 0; + long local_fine_physical_touches = 0; + long local_physical_face_ghosts = 0; + if (topology_ready) { + MultiFab& coarse = runtime.level_state(0, 0); + MultiFab& fine = runtime.level_state(0, 1); + coarse.set_val(kCoarseValue); + fine.set_val(kUntouchedGhost); + for (int local = 0; local < fine.local_size(); ++local) { + const Array4 values = fine.fab(local).array(); + for_each_cell(fine.box(local), [=] POPS_HD(int i, int j) { values(i, j, 0) = kFineValue; }); + } + device_fence(); + + MultiFab residual(fine.box_array(), fine.dmap(), fine.ncomp(), 0); + const runtime::multiblock::BoundaryEvaluationPoint point{ + "clock.adc749-amr-boundary", 0, 1, 0, 0, amr::Rational(0, 1), 0.1, 0.0}; + runtime.level_rhs_into_at(0, 1, point, fine, residual); + device_fence(); + fine.sync_host(); + + const Box2D fine_domain = runtime.level_geom(1).domain; + const BoxArray& valid_boxes = fine.box_array(); + for (const Box2D& box : valid_boxes.boxes()) + if (box.lo[0] == fine_domain.lo[0] || box.hi[0] == fine_domain.hi[0] || + box.lo[1] == fine_domain.lo[1] || box.hi[1] == fine_domain.hi[1]) + ++local_fine_physical_touches; + + for (int local = 0; local < fine.local_size(); ++local) { + const Fab2D& values = fine.fab(local); + const Box2D grown = values.grown_box(); + for (int j = grown.lo[1]; j <= grown.hi[1]; ++j) + for (int i = grown.lo[0]; i <= grown.hi[0]; ++i) { + if (!fine_domain.contains(i, j) || covered_by(valid_boxes, i, j)) + continue; + ++local_cf_ghosts; + if (std::fabs(values(i, j, 0) - kCoarseValue) > Real(1e-12)) + ++local_failures; + } + } + + // The same rematerialized plan must still own actual base-domain faces. This companion check + // prevents an inert boundary plan from making the internal-interface assertion vacuous. + MultiFab coarse_residual(coarse.box_array(), coarse.dmap(), coarse.ncomp(), 0); + const runtime::multiblock::BoundaryEvaluationPoint coarse_point{ + "clock.adc749-amr-boundary", 0, 0, 0, 0, amr::Rational(0, 1), 0.1, 0.0}; + runtime.level_rhs_into_at(0, 0, coarse_point, coarse, coarse_residual); + device_fence(); + coarse.sync_host(); + const Box2D coarse_domain = runtime.level_geom(0).domain; + for (int local = 0; local < coarse.local_size(); ++local) { + const Fab2D& values = coarse.fab(local); + const Box2D valid = coarse.box(local); + auto check = [&](int i, int j) { + ++local_physical_face_ghosts; + if (std::fabs(values(i, j, 0) - kExpectedPhysicalGhost) > Real(1e-12)) + ++local_failures; + }; + if (valid.lo[0] == coarse_domain.lo[0]) + for (int j = valid.lo[1]; j <= valid.hi[1]; ++j) + check(coarse_domain.lo[0] - 1, j); + if (valid.hi[0] == coarse_domain.hi[0]) + for (int j = valid.lo[1]; j <= valid.hi[1]; ++j) + check(coarse_domain.hi[0] + 1, j); + if (valid.lo[1] == coarse_domain.lo[1]) + for (int i = valid.lo[0]; i <= valid.hi[0]; ++i) + check(i, coarse_domain.lo[1] - 1); + if (valid.hi[1] == coarse_domain.hi[1]) + for (int i = valid.lo[0]; i <= valid.hi[0]; ++i) + check(i, coarse_domain.hi[1] + 1); + } + } + + const long failures = all_reduce_sum(local_failures); + const long cf_ghosts = all_reduce_sum(local_cf_ghosts); + const long fine_physical_touches = all_reduce_max(local_fine_physical_touches); + const long physical_face_ghosts = all_reduce_sum(local_physical_face_ghosts); + const double patch_spread = all_reduce_max(static_cast(runtime.n_patches())) - + (-all_reduce_max(-static_cast(runtime.n_patches()))); + const bool qualified = topology_ready && failures == 0 && cf_ghosts > 0 && + fine_physical_touches == 0 && physical_face_ghosts > 0 && + patch_spread == 0.0 && runtime.regrid_count() == 2; + + if (me == 0) + std::printf( + "ADC749_BOUNDARY_CF np=%d | removed=%d regrown=%d regrids=%d patches=%d | " + "cf_ghosts=%ld fine_physical_touches=%ld physical_face_ghosts=%ld failures=%ld " + "spread=%.1f\n", + np, removed ? 1 : 0, regrown ? 1 : 0, runtime.regrid_count(), runtime.n_patches(), + cf_ghosts, fine_physical_touches, physical_face_ghosts, failures, patch_spread); + return qualified ? 0 : 1; +} + +int pops_run_test_mpi_amr_prepared_boundary_cf(int argc, char** argv) { + comm_init(&argc, &argv); +#if defined(POPS_HAS_KOKKOS) + Kokkos::ScopeGuard guard(argc, argv); +#else + (void)argc; + (void)argv; +#endif + const int result = run_prepared_boundary_cf_regrid(my_rank(), n_ranks()); + comm_finalize(); + return result; +} + +} // namespace + +TEST(test_mpi_amr_prepared_boundary_cf, Runs) { + EXPECT_EQ(pops::test::RunTestBody(&pops_run_test_mpi_amr_prepared_boundary_cf, + "test_mpi_amr_prepared_boundary_cf"), + 0); +} diff --git a/tests/cpp/integration/mpi/test_mpi_fillboundary.cpp b/tests/cpp/integration/mpi/test_mpi_fillboundary.cpp index 43ada0850..343e6d5fa 100644 --- a/tests/cpp/integration/mpi/test_mpi_fillboundary.cpp +++ b/tests/cpp/integration/mpi/test_mpi_fillboundary.cpp @@ -116,15 +116,13 @@ static int pops_run_test_mpi_fillboundary(int argc, char** argv) { for (int i = valid.lo[0]; i <= valid.hi[0]; ++i) field(i, j, 0) = mapped_value(i, j); } - BCRec boundary; - boundary.xlo = BCType::Periodic; - boundary.xhi = BCType::Periodic; - boundary.ylo = BCType::Foextrap; - boundary.yhi = BCType::Foextrap; const PeriodicIdentification2D reflected_x{0, 1, std::array{{0, 1}}, std::array{{1, -1}}}; - PreparedBoundaryPlan plan("test::mpi::reflected-periodic", mapped_ng, {boundary}, {}, "", {}, - {reflected_x}); + auto boundary = prepare_hyperbolic_boundary<2>( + {"periodic", "periodic", "foextrap", "foextrap"}, std::vector(4, 0.0), + {"test::mpi::xlo", "test::mpi::xhi", "test::mpi::ylo", "test::mpi::yhi"}, {"Scalar"}, true); + PreparedBoundaryPlan plan("test::mpi::reflected-periodic", mapped_ng, std::move(boundary), {}, + "", {}, {reflected_x}); plan.fill_same_level_and_physical(mapped, mapped_domain); diff --git a/tests/cpp/integration/mpi/test_mpi_flux_failure_collective.cpp b/tests/cpp/integration/mpi/test_mpi_flux_failure_collective.cpp index ac257a3b5..78836b4a9 100644 --- a/tests/cpp/integration/mpi/test_mpi_flux_failure_collective.cpp +++ b/tests/cpp/integration/mpi/test_mpi_flux_failure_collective.cpp @@ -32,6 +32,15 @@ struct RecordOneFailure { } }; +struct RecordOneRecovery { + pops::FluxEvaluationRecorder recorder; + pops::RecoveryReport report; + + POPS_HD void operator()(int, int, std::uint64_t& failure) const { + recorder.record_recovery(report, failure); + } +}; + int run_mpi_flux_failure_collective(int argc, char** argv) { pops::comm_init(&argc, &argv); const int rank = pops::my_rank(); @@ -50,6 +59,21 @@ int run_mpi_flux_failure_collective(int argc, char** argv) { ++failures; } + { + pops::FluxEvaluationTracker tracker{pops::process_world_flux_collective}; + pops::RecoveryReport recovery; + recovery.status = + rank == 0 ? pops::RecoveryStatus::kRecovered : pops::RecoveryStatus::kRejected; + recovery.cause = + rank == 0 ? pops::RecoveryCause::kNone : pops::RecoveryCause::kExplicitRejection; + recovery.reason_code = rank == 0 ? 0u : 0x755u; + tracker.merge(pops::reduce_max_uint64_cell(pops::Box2D{{0, 0}, {0, 0}}, + RecordOneRecovery{tracker.recorder(), recovery})); + const pops::FluxFailureReport report = tracker.collective_report(); + if (report.status != pops::EvaluationStatus::kReject || report.reason_code != 0x755u) + ++failures; + } + { pops::FluxEvaluationTracker tracker{pops::process_world_flux_collective}; const auto status = rank == 0 ? pops::EvaluationStatus::kFailed : pops::EvaluationStatus::kOk; diff --git a/tests/cpp/integration/mpi/test_mpi_system_analytic_level_set.cpp b/tests/cpp/integration/mpi/test_mpi_system_analytic_level_set.cpp index 71579a3db..af07c5ead 100644 --- a/tests/cpp/integration/mpi/test_mpi_system_analytic_level_set.cpp +++ b/tests/cpp/integration/mpi/test_mpi_system_analytic_level_set.cpp @@ -1,6 +1,9 @@ #include #include "gtest_compat.hpp" +#include +#include +#include #include #include #include @@ -190,6 +193,151 @@ int run_analytic_level_set_collective_preflight(int argc, char** argv) { require(all_reduce_sum(valid_amr_registered ? 1L : 0L) == n_ranks(), "a rejected local AMR error must not leak a partial registration"); + const std::vector boundary_types{"dirichlet", "foextrap", "foextrap", "foextrap"}; + const std::vector boundary_values(4, 0.0); + const std::vector boundary_faces{"case::boundary::xlo", "case::boundary::xhi", + "case::boundary::ylo", "case::boundary::yhi"}; + const std::vector boundary_roles{"Scalar"}; + const std::vector boundary_representations(4, "conservative"); + const std::vector boundary_converters(4, ""); + const std::vector boundary_clocks(4, ""); + const std::vector> boundary_literals{{1.0}, {}, {}, {}}; + + // Boundary programs are prepared and allocate native tables during installation. A malformed + // opcode on one rank must reject every rank before the prepared-plan map publishes a node. + System boundary_system(SystemConfig{12, 1.0, Periodicity{false, false}}); + const std::string boundary_state = "case::boundary::uniform::state"; + boundary_system.install_block_state_route("tracer", boundary_state); + bool malformed_boundary_rejected = false; + std::string malformed_boundary_message; + try { + boundary_system.install_boundary_plan( + "tracer", "case::boundary::uniform::plan", 1, boundary_types, boundary_values, + boundary_faces, boundary_roles, {}, boundary_state, PreparedBoundaryReadDependencies{}, {}, + boundary_representations, boundary_converters, + {{rank == 0 ? "constant" : "not-an-analytic-opcode"}, {}, {}, {}}, boundary_literals, + boundary_clocks); + } catch (const std::runtime_error& error) { + malformed_boundary_rejected = true; + malformed_boundary_message = error.what(); + } + require(all_reduce_sum(malformed_boundary_rejected ? 1L : 0L) == n_ranks(), + "one malformed uniform analytic boundary must reject collectively"); + require(malformed_boundary_rejected && + malformed_boundary_message.find( + "rank-local analytic validation failed collectively") != std::string::npos, + "uniform boundary rejection must identify rank-local collective validation"); + + bool valid_boundary_installed = true; + try { + boundary_system.install_boundary_plan( + "tracer", "case::boundary::uniform::plan", 1, boundary_types, boundary_values, + boundary_faces, boundary_roles, {}, boundary_state, PreparedBoundaryReadDependencies{}, {}, + boundary_representations, boundary_converters, {{"constant"}, {}, {}, {}}, + boundary_literals, boundary_clocks); + } catch (const std::exception& error) { + valid_boundary_installed = false; + std::cerr << "valid uniform analytic boundary failed after malformed payload on rank " << rank + << ": " << error.what() << '\n'; + } + require(all_reduce_sum(valid_boundary_installed ? 1L : 0L) == n_ranks(), + "a rejected uniform boundary must not publish a partial plan"); + + // AMR uses the same exact transaction. Change non-program metadata only, proving that consensus + // covers the complete boundary request rather than merely its postfix rows. + AmrSystem boundary_amr(amr_config); + const std::string boundary_amr_state = "case::boundary::amr::state"; + boundary_amr.install_block_state_route("tracer", boundary_amr_state); + auto rank_faces = boundary_faces; + if (rank == 1) + rank_faces[0] = "case::boundary::rank-one-xlo"; + bool boundary_metadata_rejected = false; + std::string boundary_metadata_message; + try { + boundary_amr.install_boundary_plan( + "tracer", "case::boundary::amr::plan", 1, boundary_types, boundary_values, rank_faces, + boundary_roles, {}, boundary_amr_state, PreparedBoundaryReadDependencies{}, {}, + boundary_representations, boundary_converters, {{"constant"}, {}, {}, {}}, + boundary_literals, boundary_clocks); + } catch (const std::runtime_error& error) { + boundary_metadata_rejected = true; + boundary_metadata_message = error.what(); + } + require(all_reduce_sum(boundary_metadata_rejected ? 1L : 0L) == n_ranks(), + "rank-dependent AMR analytic boundary metadata must reject collectively"); + require(boundary_metadata_rejected && + boundary_metadata_message.find("differs across MPI ranks") != std::string::npos, + "AMR boundary metadata rejection must identify exact MPI disagreement"); + + bool valid_amr_boundary_installed = true; + try { + boundary_amr.install_boundary_plan( + "tracer", "case::boundary::amr::plan", 1, boundary_types, boundary_values, boundary_faces, + boundary_roles, {}, boundary_amr_state, PreparedBoundaryReadDependencies{}, {}, + boundary_representations, boundary_converters, {{"constant"}, {}, {}, {}}, + boundary_literals, boundary_clocks); + } catch (const std::exception& error) { + valid_amr_boundary_installed = false; + std::cerr << "valid AMR analytic boundary failed after metadata mismatch on rank " << rank + << ": " << error.what() << '\n'; + } + require(all_reduce_sum(valid_amr_boundary_installed ? 1L : 0L) == n_ranks(), + "a rejected AMR boundary mismatch must not publish a partial plan"); + + // The analytic finite-value scan must precede every same-level, periodic and MPI halo write. + // This distributed multibox field exercises all three paths and compares the complete grown + // storage after the collective refusal. + const Box2D halo_domain = Box2D::from_extents(8, 4); + const Geometry halo_geometry(halo_domain, Real(0), Real(8), Real(0), Real(4)); + const BoxArray halo_boxes = BoxArray::from_domain(halo_domain, 2); + MultiFab halo_state(halo_boxes, DistributionMapping(halo_boxes.size(), n_ranks()), 1, 1); + halo_state.set_val(Real(-99)); + for (int local = 0; local < halo_state.local_size(); ++local) { + const Array4 values = halo_state.fab(local).array(); + for_each_cell(halo_state.box(local), + [=](int i, int j) { values(i, j, 0) = Real(2 + i + 10 * j); }); + } + device_fence(); + const MultiFab halo_before = halo_state; + PreparedBoundaryPlan invalid_halo_plan( + "case::boundary::invalid-halo-plan", 1, + prepare_hyperbolic_boundary<2>( + {"dirichlet", "foextrap", "periodic", "periodic"}, std::vector(4, 0.0), + {"case::invalid-halo::xlo", "case::invalid-halo::xhi", "case::invalid-halo::ylo", + "case::invalid-halo::yhi"}, + {"Scalar"}, false, {}, {}, + {{"constant", "log"}, + std::vector{}, + std::vector{}, + std::vector{}}, + {{-1.0, 0.0}, std::vector{}, std::vector{}, std::vector{}}, + {"", "", "", ""})); + const auto invalid_halo_lane = ExecutionLane::world("case::boundary::invalid-halo-lane"); + auto invalid_halo_session = invalid_halo_plan.make_session(invalid_halo_lane); + const runtime::multiblock::BoundaryEvaluationPoint halo_point{"clock.boundary", 1, 0, 0, 0, + amr::Rational(0, 1), 0.1, 0.25}; + bool invalid_halo_rejected = false; + try { + invalid_halo_session.fill_same_level_and_physical(halo_state, halo_geometry, halo_point); + } catch (const std::runtime_error&) { + invalid_halo_rejected = true; + } + require(all_reduce_sum(invalid_halo_rejected ? 1L : 0L) == n_ranks(), + "non-finite analytic halo values must reject collectively"); + halo_state.sync_host(); + halo_before.sync_host(); + long local_halo_mutations = 0; + for (int local = 0; local < halo_state.local_size(); ++local) { + const Fab2D& observed = halo_state.fab(local); + const Fab2D& expected = halo_before.fab(local); + const Box2D grown = observed.grown_box(); + for (int j = grown.lo[1]; j <= grown.hi[1]; ++j) + for (int i = grown.lo[0]; i <= grown.hi[0]; ++i) + local_halo_mutations += observed(i, j, 0) == expected(i, j, 0) ? 0L : 1L; + } + require(all_reduce_sum(local_halo_mutations) == 0, + "analytic refusal must preserve complete same-level, periodic, MPI and physical storage"); + const long failures = all_reduce_sum(local_failures); comm_finalize(); return failures == 0 ? 0 : 1; diff --git a/tests/cpp/integration/native_loader/test_amr_native_loader.cpp b/tests/cpp/integration/native_loader/test_amr_native_loader.cpp index 0879038c2..01ae43ffc 100644 --- a/tests/cpp/integration/native_loader/test_amr_native_loader.cpp +++ b/tests/cpp/integration/native_loader/test_amr_native_loader.cpp @@ -94,6 +94,48 @@ std::string component_source() { return 0; } + int transform_boundary_flux(void* state, const PopsBoundaryFluxRequestV1* request, + PopsBoundaryFluxResultV1* result) { + if (result == nullptr) + return 42; + if (state == nullptr || request == nullptr || request->region.kind != POPS_BOUNDARY_FACE_V1 || + request->region.axis_count != 1 || request->region.axes == nullptr || + request->region.sides == nullptr || request->outward_normals.data == nullptr || + request->face_measures == nullptr || result->outward_normal_flux.data == nullptr) { + result->status = {sizeof(PopsComponentStatusV1), 42, POPS_COMPONENT_ABORT_RUN_V1, + "boundary flux contract is incomplete"}; + return 42; + } + const auto* base = static_cast(request->base_outward_normal_flux.data); + const auto* normals = static_cast(request->outward_normals.data); + auto* output = static_cast(result->outward_normal_flux.data); + const auto points = request->base_outward_normal_flux.extents[0] * + request->base_outward_normal_flux.extents[1]; + const auto axis = static_cast(request->region.axes[0]); + const double side = static_cast(request->region.sides[0]); + for (std::size_t point = 0; point < points; ++point) { + const auto normal_offset = + point * static_cast(request->outward_normals.axis_strides[0]) + + axis * static_cast(request->outward_normals.component_stride); + if (normals[normal_offset] != side || request->face_measures[point] <= 0.0) { + result->status = {sizeof(PopsComponentStatusV1), 43, POPS_COMPONENT_ABORT_RUN_V1, + "boundary flux orientation is inconsistent"}; + return 43; + } + for (std::size_t component = 0; + component < request->base_outward_normal_flux.component_count; ++component) { + const auto index = + point * static_cast(request->base_outward_normal_flux.axis_strides[0]) + + component * + static_cast(request->base_outward_normal_flux.component_stride); + output[index] = base[index] + 10.0; + } + result->actions[point] = POPS_COMPONENT_CONTINUE_V1; + } + result->status = {sizeof(PopsComponentStatusV1), 0, POPS_COMPONENT_CONTINUE_V1, nullptr}; + return 0; + } + int tag_batch(void*, const PopsTaggerRequestV2* request, PopsComponentStatusV1* status) { ++tag_call_count; last_tag_state_data = request->states[0].values.data; @@ -276,6 +318,10 @@ std::string component_source() { {sizeof(PopsGhostBoundaryApiV1), POPS_COMPONENT_PROTOCOL_ABI_V1, POPS_NATIVE_INTERFACE_GHOST_BOUNDARY_V1, 1, &prepare, &destroy}, &apply_ghost}; + const PopsBoundaryFluxApiV1 boundary_flux{ + {sizeof(PopsBoundaryFluxApiV1), POPS_COMPONENT_PROTOCOL_ABI_V1, + POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1, 1, &prepare, &destroy}, + &transform_boundary_flux}; const PopsTaggerApiV2 tagger{{sizeof(PopsTaggerApiV2), POPS_COMPONENT_PROTOCOL_ABI_V1, POPS_NATIVE_INTERFACE_TAGGER_V2, 2, &prepare, &destroy}, &tag_batch}; @@ -292,6 +338,7 @@ std::string component_source() { #endif {POPS_NATIVE_INTERFACE_TRANSFER_V1, 1, sizeof(PopsTransferApiV1), &transfer}, {POPS_NATIVE_INTERFACE_GHOST_BOUNDARY_V1, 1, sizeof(PopsGhostBoundaryApiV1), &ghost}, + {POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1, 1, sizeof(PopsBoundaryFluxApiV1), &boundary_flux}, {POPS_NATIVE_INTERFACE_TAGGER_V2, 2, sizeof(PopsTaggerApiV2), &tagger}, {POPS_NATIVE_INTERFACE_CLUSTERING_V1, 1, sizeof(PopsClusteringApiV1), &clustering}}; const PopsComponentApiV1 component{ @@ -306,7 +353,7 @@ std::string component_source() { "pops://test/final-flux@1.0.0", "semantic-final-flux", "manifest-final-flux", - 5, + 6, interfaces}; } // namespace @@ -377,6 +424,7 @@ pops::component::ExpectedNativeComponent expected() { {{POPS_NATIVE_INTERFACE_NUMERICAL_FLUX_V1, 1, sizeof(PopsNumericalFluxApiV1)}, {POPS_NATIVE_INTERFACE_TRANSFER_V1, 1, sizeof(PopsTransferApiV1)}, {POPS_NATIVE_INTERFACE_GHOST_BOUNDARY_V1, 1, sizeof(PopsGhostBoundaryApiV1)}, + {POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1, 1, sizeof(PopsBoundaryFluxApiV1)}, {POPS_NATIVE_INTERFACE_TAGGER_V2, 2, sizeof(PopsTaggerApiV2)}, {POPS_NATIVE_INTERFACE_CLUSTERING_V1, 1, sizeof(PopsClusteringApiV1)}}}; } @@ -1092,12 +1140,13 @@ TEST(test_amr_native_loader, BoundaryPlanSessionsOwnFreshLaneQualifiedComponentS spec.target_json = R"({"identity":"case::boundary::ghost-target"})"; spec.execution = prepared_execution(); - pops::BCRec bc; - bc.xlo = pops::BCType::Foextrap; - bc.xhi = pops::BCType::Foextrap; - bc.ylo = pops::BCType::Foextrap; - bc.yhi = pops::BCType::Foextrap; - pops::PreparedBoundaryPlan plan("case::boundary::plan", 1, {bc}, {}, spec.state_identity); + auto hyperbolic = pops::prepare_hyperbolic_boundary<2>( + {"foextrap", "foextrap", "foextrap", "foextrap"}, std::vector(4, 0.0), + {"case::boundary::xlo", "case::boundary::xhi", "case::boundary::ylo", + "case::boundary::yhi"}, + {"Scalar"}); + pops::PreparedBoundaryPlan plan("case::boundary::plan", 1, std::move(hyperbolic), {}, + spec.state_identity); plan.install_ghost_component(std::move(spec), component); const auto lane = @@ -1136,6 +1185,91 @@ TEST(test_amr_native_loader, BoundaryPlanSessionsOwnFreshLaneQualifiedComponentS std::filesystem::remove(library); } +TEST(test_amr_native_loader, + PostRiemannBoundaryFluxUsesOutwardOrientationAndPreservesCanonicalFaceStorage) { + const auto library = compile_component(); + { + auto component = std::make_shared( + pops::component::LoadedComponent::load(library.string(), expected())); + const auto make_spec = [&](std::string target, std::string boundary, int side) { + pops::PreparedBoundaryComponentSpec spec; + spec.target_identity = std::move(target); + spec.component_id = kComponentId; + spec.manifest_identity = kManifestIdentity; + spec.interface_version = 1; + spec.producer_identity = spec.target_identity; + spec.state_identity = "case::state::u"; + spec.ghost_identity = boundary; + spec.layout_identity = "case::layout::cells"; + spec.region.kind = POPS_BOUNDARY_FACE_V1; + spec.region.dimension = 2; + spec.region.codimension = 1; + spec.region.axes = {0}; + spec.region.sides = {side}; + spec.region.identity = std::move(boundary); + spec.outputs = {spec.state_identity}; + spec.parameters_json = R"({"outward_shift":10.0})"; + spec.target_json = R"({"kind":"post-riemann-flux"})"; + spec.execution = prepared_execution(); + return spec; + }; + + auto hyperbolic = pops::prepare_hyperbolic_boundary<2>( + {"foextrap", "foextrap", "foextrap", "foextrap"}, std::vector(4, 0.0), + {"case::boundary::xlo", "case::boundary::xhi", "case::boundary::ylo", + "case::boundary::yhi"}, + {"Scalar"}); + pops::PreparedBoundaryPlan plan("case::boundary::flux-plan", 1, std::move(hyperbolic), {}, + "case::state::u"); + plan.install_flux_component(make_spec("case::boundary-flux::xlo", "case::boundary::xlo", -1), + component); + plan.install_flux_component(make_spec("case::boundary-flux::xhi", "case::boundary::xhi", 1), + component); + EXPECT_TRUE(plan.has_flux_transformations()); + + const pops::Box2D domain = pops::Box2D::from_extents(3, 3); + const pops::Geometry geometry(domain, pops::Real(0), pops::Real(1), pops::Real(0), + pops::Real(1)); + const pops::BoxArray cell_boxes = pops::BoxArray::from_domain(domain, domain.nx()); + pops::MultiFab state(cell_boxes, pops::DistributionMapping(cell_boxes.size(), pops::n_ranks()), + 1, 1); + state.set_val(pops::Real(1)); + pops::Box2D xface_box = domain; + ++xface_box.hi[0]; + const pops::BoxArray xface_boxes(std::vector{xface_box}); + pops::MultiFab fx(xface_boxes, pops::DistributionMapping(xface_boxes.size(), pops::n_ranks()), + 1, 0); + pops::Box2D yface_box = domain; + ++yface_box.hi[1]; + const pops::BoxArray yface_boxes(std::vector{yface_box}); + pops::MultiFab fy(yface_boxes, pops::DistributionMapping(yface_boxes.size(), pops::n_ranks()), + 1, 0); + fx.set_val(pops::Real(3)); + fy.set_val(pops::Real(4)); + + pops::detail::BoundaryFieldRegistry fields; + fields.configure_states(plan.required_state_identities()); + fields.configure_fields(plan.required_field_identities()); + fields.begin_binding(); + const auto lane = pops::ExecutionLane::world("case::boundary::flux-session"); + auto session = plan.make_session(lane); + session.prepare_flux_executor(state, fields, geometry); + const pops::runtime::multiblock::BoundaryEvaluationPoint point{ + "clock.boundary-flux", 0, 0, 0, 0, pops::amr::Rational(0, 1), 0.1, 0.0}; + session.transform_fluxes(point, state, fields, geometry, fx, fy); + + if (fx.local_size() != 0) { + // The provider receives outward flux. Adding +10 outward therefore scatters as -7 on the + // lower face (-(-3 + 10)) and +13 on the upper face (+(+3 + 10)). + EXPECT_EQ(fx.fab(0)(domain.lo[0], 1, 0), pops::Real(-7)); + EXPECT_EQ(fx.fab(0)(domain.hi[0] + 1, 1, 0), pops::Real(13)); + EXPECT_EQ(fx.fab(0)(1, 1, 0), pops::Real(3)); + EXPECT_EQ(fy.fab(0)(1, 1, 0), pops::Real(4)); + } + } + std::filesystem::remove(library); +} + TEST(test_amr_native_loader, RefusesIdentityInterfaceAndTableSizeMismatches) { const auto library = compile_component(); auto forged = expected(); diff --git a/tests/cpp/integration/native_loader/test_flux_failure_loader_transaction.cpp b/tests/cpp/integration/native_loader/test_flux_failure_loader_transaction.cpp index 9029d8818..7b401d43b 100644 --- a/tests/cpp/integration/native_loader/test_flux_failure_loader_transaction.cpp +++ b/tests/cpp/integration/native_loader/test_flux_failure_loader_transaction.cpp @@ -54,6 +54,7 @@ std::string package_source() { #include #include +#include #include #include #include @@ -66,6 +67,14 @@ std::string package_source() { POPS_HD State flux(const State&, const Aux&, int) const { return State{}; } POPS_HD pops::Real max_wave_speed(const State&, const Aux&, int) const { return pops::Real(1); } + POPS_HD State roe_dissipation(const State& left, const Aux&, const State& right, const Aux&, + int) const { + State result{}; + if ((left[0] > pops::Real(0.902) && left[0] < pops::Real(0.908)) || + (right[0] > pops::Real(0.902) && right[0] < pops::Real(0.908))) + result[0] = std::numeric_limits::quiet_NaN(); + return result; + } POPS_HD State source(const State& state, const Aux&) const { return State{-state[0]}; } POPS_HD pops::Real elliptic_rhs(const State&) const { return pops::Real(0); } POPS_HD Prim to_primitive(const State& state) const { return state; } @@ -144,6 +153,8 @@ std::string package_source() { install_attempt_block(system, name, substeps, evolve != 0, stride); else if (params[0] == 1.0 && std::string(time) == "explicit") install_attempt_block(system, name, substeps, evolve != 0, stride); + else if (params[0] == 2.0 && std::string(time) == "explicit") + install_attempt_block(system, name, substeps, evolve != 0, stride); else throw std::invalid_argument("attempt-control test package received an invalid mode"); } @@ -216,6 +227,9 @@ int run_flux_failure_loader_transaction() { 0x52545259u); failures += exercise_attempt( library, 1.0, pops::runtime::program::StepAttemptDisposition::kReject, 0x524a4354u); + failures += exercise_attempt( + library, 2.0, pops::runtime::program::StepAttemptDisposition::kReject, + pops::riemann_reason_code(pops::RiemannFailureCause::kRoeNonFiniteDissipation)); std::remove(source.c_str()); std::remove(library.c_str()); std::remove((library + ".log").c_str()); diff --git a/tests/cpp/integration/runtime/test_facade_routing.cpp b/tests/cpp/integration/runtime/test_facade_routing.cpp index 6f076ba7d..6b06fb62b 100644 --- a/tests/cpp/integration/runtime/test_facade_routing.cpp +++ b/tests/cpp/integration/runtime/test_facade_routing.cpp @@ -84,6 +84,16 @@ ModelSpec periodic_exb_model() { return spec; } +ModelSpec compressible_model() { + ModelSpec spec; + spec.transport = "compressible"; + spec.source = "none"; + spec.elliptic = "background"; + spec.gamma = 1.4; + spec.n0 = 0.0; + return spec; +} + // Construit un System scalaire ExB diocotron pret a stepper. Le disque/mode est pose par l'appelant. void build_exb(System& s, double R_wall) { ModelSpec spec; @@ -347,3 +357,31 @@ TEST(FacadeRouting, PeriodicAnalyticLevelSetUsesTopologyAtTheSeam) { EXPECT_EQ(topology.get_state("n"), explicit_wrap.get_state("n")); EXPECT_GT(max_abs_diff(topology.get_state("n"), rho0), 0.0); } + +TEST(FacadeRouting, PrimitiveMaterializationFailsClosedWithoutMutatingAcceptedState) { +#if defined(POPS_HAS_KOKKOS) + (void)kokkos_scope(); +#endif + constexpr int n = 4; + System system(SystemConfig{n, 1.0, Periodicity{true, true}}); + system.add_block("gas", compressible_model(), "none", "rusanov", "conservative"); + + // All components are finite, but Euler conservative -> primitive is undefined at rho=0. + // This exercises the real runtime registry and its prepared conversion, not a test-only callback. + const std::vector accepted(static_cast(4 * n * n), 0.0); + system.set_state("gas", accepted); + + bool rejected = false; + try { + (void)system.get_primitive_state("gas"); + } catch (const std::runtime_error& error) { + const std::string message = error.what(); + rejected = message.find("variable recovery failed") != std::string::npos && + message.find("status=invalid_contract") != std::string::npos && + message.find("cause=non_finite_candidate") != std::string::npos && + message.find("attempted_methods=1") != std::string::npos; + } + EXPECT_TRUE(rejected); + EXPECT_EQ(system.get_state("gas"), accepted) + << "failed diagnostic recovery must not mutate the accepted conservative state"; +} diff --git a/tests/cpp/integration/runtime/test_multiblock_interface_scheduler.cpp b/tests/cpp/integration/runtime/test_multiblock_interface_scheduler.cpp index d0a1d450a..7da6b52ea 100644 --- a/tests/cpp/integration/runtime/test_multiblock_interface_scheduler.cpp +++ b/tests/cpp/integration/runtime/test_multiblock_interface_scheduler.cpp @@ -1534,8 +1534,12 @@ TEST(test_multiblock_interface_scheduler, AmrBoundaryRegistryUsesOtherBlocksProv blocks[0].state_identity = a_state; blocks[1].state_identity = b_state; blocks[0].boundary_plan = std::make_shared( - "case::amr::a::boundary", 1, std::vector{BCRec{}}, std::vector{}, a_state, - PreparedBoundaryReadDependencies{{b_state}, {}}); + "case::amr::a::boundary", 1, + prepare_hyperbolic_boundary<2>( + {"periodic", "periodic", "periodic", "periodic"}, std::vector(4, 0.0), + {"case::amr::a::xlo", "case::amr::a::xhi", "case::amr::a::ylo", "case::amr::a::yhi"}, + {"Scalar"}), + std::vector{}, a_state, PreparedBoundaryReadDependencies{{b_state}, {}}); const auto b_read = blocks[0].boundary_plan->prepare_state_read(b_state); blocks[0].boundary_field_registry = std::make_shared(); blocks[0].level_rhs_core_at_point_prepared = diff --git a/tests/cpp/integration/runtime/test_route_ids.cpp b/tests/cpp/integration/runtime/test_route_ids.cpp index 46b4f1ef2..ff283eada 100644 --- a/tests/cpp/integration/runtime/test_route_ids.cpp +++ b/tests/cpp/integration/runtime/test_route_ids.cpp @@ -128,11 +128,11 @@ TEST(RouteIds, UnknownTokenRefusedWithFamilyTokenValidSetAndNoDefaultPhrase) { << "field_solver 'amg' refuse (famille, token, set valide, no-default)"; } { - const std::string m = throw_message([] { parse_limiter_route("superbee"); }); - EXPECT_TRUE(contains(m, "limiter") && contains(m, "superbee") && - contains(m, "none|minmod|vanleer|weno5") && + const std::string m = throw_message([] { parse_limiter_route("koren"); }); + EXPECT_TRUE(contains(m, "limiter") && contains(m, "koren") && + contains(m, "none|minmod|vanleer|weno5|mc|superbee") && contains(m, "never fall back to a default")) - << "limiter 'superbee' refuse (famille, token, set valide, no-default)"; + << "limiter 'koren' refuse (famille, token, set valide, no-default)"; } { const std::string m = throw_message([] { parse_transport_route("upwind"); }); diff --git a/tests/cpp/test_durations.json b/tests/cpp/test_durations.json index 855ccd973..43a0796b3 100644 --- a/tests/cpp/test_durations.json +++ b/tests/cpp/test_durations.json @@ -6,7 +6,8 @@ "test_amr_program_diffusion", "test_amr_program_positivity_floor", "test_flux_failure_loader_transaction", - "test_interface_flux_fragment_ledger" + "test_interface_flux_fragment_ledger", + "test_temporal_partition_restart" ], "estimate_policy": "new unmeasured targets use a conservative analogous-target estimate, falling back to the catalog median until the next CTest timing refresh", "measured_refresh": { @@ -16,7 +17,7 @@ "refresh_source_run": "30190778708", "source_job": "87152034744", "source_run": "29352485297", - "target_count": 187, + "target_count": 188, "unit_seconds": "aggregate CTest wall time per build target" }, "test_adaptive_multirate": 0.02, @@ -161,6 +162,7 @@ "test_polar_transport_mms": 2.56, "test_positivity_floor": 0.02, "test_prepared_boundary_plan": 0.02, + "test_prepared_numerics_gate": 0.02, "test_primitive_recon": 0.01, "test_pure_field_algebra_extreme_dot": 0.02, "test_profiler": 0.04, @@ -191,6 +193,7 @@ "test_step_attempt_rejected_header_only": 0.01, "test_structured_solver_diagnostics": 0.01, "test_sync_residence": 0.01, + "test_temporal_partition_restart": 0.05, "test_system_abstraction": 0.01, "test_system_coupler": 0.01, "test_system_hardening": 0.01, @@ -199,6 +202,7 @@ "test_two_species_minimal": 0.13, "test_user_time_integrator": 0.01, "test_variable_epsilon": 0.37, + "test_variable_recovery_chain": 0.02, "test_variable_role": 0.01, "test_variable_user_role": 0.01, "test_wave_speed_cache_engagement": 0.03, diff --git a/tests/cpp/test_sources.cmake b/tests/cpp/test_sources.cmake index 8dee480e5..e0552ce56 100644 --- a/tests/cpp/test_sources.cmake +++ b/tests/cpp/test_sources.cmake @@ -55,6 +55,7 @@ set(POPS_CPP_TEST_SOURCE_test_capability_report "tests/cpp/integration/runtime/t set(POPS_CPP_TEST_SOURCE_test_canonical_identity "tests/cpp/unit/core/test_canonical_identity.cpp") set(POPS_CPP_TEST_SOURCE_test_cf_interface "tests/cpp/integration/amr/test_cf_interface.cpp") set(POPS_CPP_TEST_SOURCE_test_program_reflux_ledger "tests/cpp/integration/amr/test_program_reflux_ledger.cpp") +set(POPS_CPP_TEST_SOURCE_test_temporal_partition_restart "tests/cpp/integration/amr/test_temporal_partition_restart.cpp") set(POPS_CPP_TEST_SOURCE_test_cfl_dt "tests/cpp/unit/numerics/test_cfl_dt.cpp") set(POPS_CPP_TEST_SOURCE_test_checkpoint_cache "tests/cpp/integration/runtime/test_checkpoint_cache.cpp") set(POPS_CPP_TEST_SOURCE_test_checkpoint_history "tests/cpp/integration/runtime/test_checkpoint_history.cpp") @@ -123,6 +124,7 @@ set(POPS_CPP_TEST_SOURCE_test_multiblock_interface_scheduler "tests/cpp/integrat set(POPS_CPP_TEST_SOURCE_test_mpi_amr_compiled_parity "tests/cpp/integration/mpi/test_mpi_amr_compiled_parity.cpp") set(POPS_CPP_TEST_SOURCE_test_mpi_amr_distributed_coarse "tests/cpp/integration/mpi/test_mpi_amr_distributed_coarse.cpp") set(POPS_CPP_TEST_SOURCE_test_mpi_amr_dynamic_active_depth "tests/cpp/integration/mpi/test_mpi_amr_dynamic_active_depth.cpp") +set(POPS_CPP_TEST_SOURCE_test_mpi_amr_prepared_boundary_cf "tests/cpp/integration/mpi/test_mpi_amr_prepared_boundary_cf.cpp") set(POPS_CPP_TEST_SOURCE_test_mpi_amr_program_reflux "tests/cpp/integration/mpi/test_mpi_amr_program_reflux.cpp") set(POPS_CPP_TEST_SOURCE_test_mpi_amr_twoblock_parity "tests/cpp/integration/mpi/test_mpi_amr_twoblock_parity.cpp") set(POPS_CPP_TEST_SOURCE_test_mpi_array_reduce "tests/cpp/integration/mpi/test_mpi_array_reduce.cpp") @@ -174,6 +176,7 @@ set(POPS_CPP_TEST_SOURCE_test_polar_system_step "tests/cpp/integration/runtime/t set(POPS_CPP_TEST_SOURCE_test_polar_tensor_elliptic_mms "tests/cpp/unit/elliptic/test_polar_tensor_elliptic_mms.cpp") set(POPS_CPP_TEST_SOURCE_test_polar_transport_mms "tests/cpp/unit/physics/test_polar_transport_mms.cpp") set(POPS_CPP_TEST_SOURCE_test_positivity_floor "tests/cpp/unit/numerics/test_positivity_floor.cpp") +set(POPS_CPP_TEST_SOURCE_test_prepared_numerics_gate "tests/cpp/unit/numerics/test_prepared_numerics_gate.cpp") set(POPS_CPP_TEST_SOURCE_test_primitive_recon "tests/cpp/unit/numerics/test_primitive_recon.cpp") set(POPS_CPP_TEST_SOURCE_test_pure_field_algebra_extreme_dot "tests/cpp/unit/elliptic/test_pure_field_algebra_extreme_dot.cpp") set(POPS_CPP_TEST_SOURCE_test_profiler "tests/cpp/integration/runtime/test_profiler.cpp") @@ -212,6 +215,7 @@ set(POPS_CPP_TEST_SOURCE_test_system_two_explicit "tests/cpp/integration/runtime set(POPS_CPP_TEST_SOURCE_test_two_species_minimal "tests/cpp/unit/physics/test_two_species_minimal.cpp") set(POPS_CPP_TEST_SOURCE_test_user_time_integrator "tests/cpp/unit/physics/test_user_time_integrator.cpp") set(POPS_CPP_TEST_SOURCE_test_variable_epsilon "tests/cpp/unit/elliptic/test_variable_epsilon.cpp") +set(POPS_CPP_TEST_SOURCE_test_variable_recovery_chain "tests/cpp/unit/numerics/test_variable_recovery_chain.cpp") set(POPS_CPP_TEST_SOURCE_test_variable_role "tests/cpp/unit/runtime/test_variable_role.cpp") set(POPS_CPP_TEST_SOURCE_test_variable_user_role "tests/cpp/unit/runtime/test_variable_user_role.cpp") set(POPS_CPP_TEST_SOURCE_test_wave_speed_cache_engagement "tests/cpp/integration/runtime/test_wave_speed_cache_engagement.cpp") diff --git a/tests/cpp/unit/codegen/test_block_builder.cpp b/tests/cpp/unit/codegen/test_block_builder.cpp index ec684a924..d32a65492 100644 --- a/tests/cpp/unit/codegen/test_block_builder.cpp +++ b/tests/cpp/unit/codegen/test_block_builder.cpp @@ -23,7 +23,9 @@ #include #include +#include #include +#include #include using namespace pops; @@ -148,3 +150,39 @@ TEST(test_block_builder, isothermal_model_without_hllc_capability_is_rejected) { EXPECT_TRUE(refused_with("hllc", "capability")) << "isotherme + hllc refuse (nomme la capability)"; } + +TEST(test_block_builder, cell_primitive_conversion_consumes_prepared_recovery_outcome) { + const Model model{Euler{1.4}, GravityForce{}, GravityCoupling{-1.0, 1.0, 1.0}}; + const auto conversion = make_cell_convert(model); + + // rho=1, (u,v)=(0.2,-0.1), p=1 -> E=p/(gamma-1)+rho*(u^2+v^2)/2=2.525. + const std::array conservative{1.0, 0.2, -0.1, 2.525}; + std::array primitive{-9.0, -9.0, -9.0, -9.0}; + const RecoveryReport success = conversion.second(conservative.data(), primitive.data()); + EXPECT_TRUE(success.recovered()); + EXPECT_EQ(success.status, RecoveryStatus::kRecovered); + EXPECT_EQ(success.cause, RecoveryCause::kNone); + EXPECT_EQ(success.attempted_methods, 1); + EXPECT_EQ(success.selected_method, 0); + EXPECT_EQ(success.selected_method_kind, RecoveryMethodKind::kClosedForm); + EXPECT_EQ(success.last_method_kind, RecoveryMethodKind::kClosedForm); + EXPECT_DOUBLE_EQ(primitive[0], 1.0); + EXPECT_DOUBLE_EQ(primitive[1], 0.2); + EXPECT_DOUBLE_EQ(primitive[2], -0.1); + EXPECT_NEAR(primitive[3], 1.0, 1e-14); + + // The Euler closed form produces non-finite velocity/pressure for rho=0. The common prepared + // authority rejects that candidate and the type-erased closure must leave output byte-exact. + const std::array invalid_conservative{0.0, 0.0, 0.0, 0.0}; + const std::array sentinel{1.25, -2.5, 3.75, -5.0}; + primitive = sentinel; + const RecoveryReport failure = conversion.second(invalid_conservative.data(), primitive.data()); + EXPECT_FALSE(failure.publication_permitted()); + EXPECT_EQ(failure.status, RecoveryStatus::kInvalidContract); + EXPECT_EQ(failure.cause, RecoveryCause::kNonFiniteCandidate); + EXPECT_EQ(failure.attempted_methods, 1); + EXPECT_EQ(failure.selected_method_kind, RecoveryMethodKind::kUnknown); + EXPECT_EQ(failure.last_method_kind, RecoveryMethodKind::kClosedForm); + EXPECT_GE(failure.failing_component, 1); + EXPECT_EQ(primitive, sentinel); +} diff --git a/tests/cpp/unit/mesh/test_dispatch_tags.cpp b/tests/cpp/unit/mesh/test_dispatch_tags.cpp index b8397fdac..a46dc1724 100644 --- a/tests/cpp/unit/mesh/test_dispatch_tags.cpp +++ b/tests/cpp/unit/mesh/test_dispatch_tags.cpp @@ -100,11 +100,15 @@ TEST(test_dispatch_tags, limiter_n_ghost_widths) { EXPECT_EQ(limiter_n_ghost("minmod"), 2) << "n_ghost(minmod) == 2"; EXPECT_EQ(limiter_n_ghost("vanleer"), 2) << "n_ghost(vanleer) == 2"; EXPECT_EQ(limiter_n_ghost("weno5"), 3) << "n_ghost(weno5) == 3"; + EXPECT_EQ(limiter_n_ghost("mc"), 2) << "n_ghost(mc) == 2"; + EXPECT_EQ(limiter_n_ghost("superbee"), 2) << "n_ghost(superbee) == 2"; EXPECT_THROW((void)limiter_n_ghost("bogus"), std::runtime_error) << "an unknown limiter must never select a fallback halo"; // variante compile-time (utilisee par les static_assert de non-derive de block_builder.hpp). static_assert(limiter_n_ghost_ct("none") == 1, "ct none"); static_assert(limiter_n_ghost_ct("weno5") == 3, "ct weno5"); + static_assert(limiter_n_ghost_ct("mc") == 2, "ct mc"); + static_assert(limiter_n_ghost_ct("superbee") == 2, "ct superbee"); static_assert(limiter_n_ghost_ct("bogus") == -1, "ct inconnu == -1"); } @@ -113,6 +117,10 @@ TEST(test_dispatch_tags, klimiters_kriemanns_tables) { << "kLimiters[0]"; EXPECT_TRUE(std::string(kLimiters[3].name) == "weno5" && kLimiters[3].n_ghost == 3) << "kLimiters[3]"; + EXPECT_TRUE(std::string(kLimiters[4].name) == "mc" && kLimiters[4].n_ghost == 2) + << "kLimiters[4]"; + EXPECT_TRUE(std::string(kLimiters[5].name) == "superbee" && kLimiters[5].n_ghost == 2) + << "kLimiters[5]"; EXPECT_TRUE(std::string(kRiemanns[0].name) == "rusanov" && kRiemanns[0].polar_ok) << "kRiemanns[0] rusanov polar_ok"; EXPECT_TRUE(std::string(kRiemanns[1].name) == "hll" && kRiemanns[1].needs_wave_speeds && diff --git a/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp index d262516c5..3bc169dfa 100644 --- a/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp +++ b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp @@ -1,11 +1,13 @@ #include +#include #include #include #include #include #include +#include #include #include @@ -18,23 +20,42 @@ MultiFab scalar_field(const Box2D& domain, int ncomp = 1, int ngrow = 0) { return MultiFab(boxes, DistributionMapping(boxes.size(), n_ranks()), ncomp, ngrow); } -BCRec physical_bc() { - BCRec bc; - bc.xlo = BCType::Foextrap; - bc.xhi = BCType::Dirichlet; - bc.xhi_val = Real(4); - bc.ylo = BCType::Foextrap; - bc.yhi = BCType::Foextrap; - return bc; +PreparedHyperbolicBoundary<2> physical_boundary(std::vector xhi_values = {4.0}, + std::vector roles = {"Scalar"}) { + std::vector values; + values.reserve(4 * xhi_values.size()); + for (double value : xhi_values) + values.insert(values.end(), {0.0, value, 0.0, 0.0}); + return prepare_hyperbolic_boundary<2>({"foextrap", "dirichlet", "foextrap", "foextrap"}, values, + {"case::xlo", "case::xhi", "case::ylo", "case::yhi"}, + roles); } -BCRec reflected_x_periodic_bc() { - BCRec bc; - bc.xlo = BCType::Periodic; - bc.xhi = BCType::Periodic; - bc.ylo = BCType::Foextrap; - bc.yhi = BCType::Foextrap; - return bc; +PreparedHyperbolicBoundary<2> periodic_boundary( + std::vector face_types = {"periodic", "periodic", "foextrap", "foextrap"}, + bool explicit_identifications = false) { + if (face_types.empty()) + face_types = {"periodic", "periodic", "foextrap", "foextrap"}; + return prepare_hyperbolic_boundary<2>( + face_types, std::vector(4, 0.0), + {"case::periodic::xlo", "case::periodic::xhi", "case::periodic::ylo", "case::periodic::yhi"}, + {"Scalar"}, explicit_identifications); +} + +PreparedHyperbolicBoundary<2> analytic_xlo_boundary( + std::vector opcodes = {"x", "y", "add", "input", "add"}, + std::vector literals = {0.0, 0.0, 0.0, 0.0, 0.0}, bool periodic_tangent = false) { + const bool reads_time = std::find(opcodes.begin(), opcodes.end(), "input") != opcodes.end(); + return prepare_hyperbolic_boundary<2>( + periodic_tangent ? std::vector{"dirichlet", "foextrap", "periodic", "periodic"} + : std::vector{"dirichlet", "foextrap", "foextrap", "foextrap"}, + std::vector(4, 0.0), + {"case::analytic::xlo", "case::analytic::xhi", "case::analytic::ylo", "case::analytic::yhi"}, + {"Scalar"}, false, {}, {}, + {std::move(opcodes), std::vector{}, std::vector{}, + std::vector{}}, + {std::move(literals), std::vector{}, std::vector{}, std::vector{}}, + {reads_time ? "clock.analytic" : "", "", "", ""}); } PreparedBoundaryComponentSpec linearization_spec(bool jvp, std::string target, std::string output) { @@ -69,18 +90,18 @@ PreparedBoundaryComponentSpec linearization_spec(bool jvp, std::string target, s TEST(test_prepared_boundary_plan, explicit_read_dependencies_are_exact_and_strict) { PreparedBoundaryPlan plan( - "case::boundary::read-dependencies", 1, {physical_bc()}, {}, "case::state::primary", + "case::boundary::read-dependencies", 1, physical_boundary(), {}, "case::state::primary", PreparedBoundaryReadDependencies{{"case::state::other"}, {"case::field::potential"}}); EXPECT_EQ(plan.required_state_identities(), std::vector{"case::state::other"}); EXPECT_EQ(plan.required_field_identities(), std::vector{"case::field::potential"}); EXPECT_THROW( PreparedBoundaryPlan( - "case::boundary::duplicate-state", 1, {physical_bc()}, {}, "case::state::primary", + "case::boundary::duplicate-state", 1, physical_boundary(), {}, "case::state::primary", PreparedBoundaryReadDependencies{{"case::state::other", "case::state::other"}, {}}), std::runtime_error); EXPECT_THROW( - PreparedBoundaryPlan("case::boundary::empty-field", 1, {physical_bc()}, {}, + PreparedBoundaryPlan("case::boundary::empty-field", 1, physical_boundary(), {}, "case::state::primary", PreparedBoundaryReadDependencies{{}, {""}}), std::runtime_error); } @@ -91,11 +112,11 @@ TEST(test_prepared_boundary_plan, prepared_read_tokens_are_owner_bound_and_epoch MultiFab coupled = scalar_field(domain, 1, 1); MultiFab auxiliary = scalar_field(domain, 1, 0); auto plan = std::make_shared( - "case::boundary::prepared-reads", 1, std::vector{physical_bc()}, std::vector{}, + "case::boundary::prepared-reads", 1, physical_boundary(), std::vector{}, "case::state::primary", PreparedBoundaryReadDependencies{{"case::state::coupled"}, {"case::field::auxiliary"}}); auto foreign_plan = std::make_shared( - "case::boundary::foreign-reads", 1, std::vector{physical_bc()}, std::vector{}, + "case::boundary::foreign-reads", 1, physical_boundary(), std::vector{}, "case::state::primary", PreparedBoundaryReadDependencies{{"case::state::coupled"}, {}}); const auto coupled_read = plan->prepare_state_read("case::state::coupled"); const auto auxiliary_read = plan->prepare_field_read("case::field::auxiliary"); @@ -139,10 +160,8 @@ TEST(test_prepared_boundary_plan, executes_same_level_and_component_physical_pro values(i, j, 1) = Real(2); }); } - BCRec first = physical_bc(); - BCRec second = physical_bc(); - second.xhi_val = Real(9); - PreparedBoundaryPlan plan("case::block::ghost-plan", 1, {first, second}); + PreparedBoundaryPlan plan("case::block::ghost-plan", 1, + physical_boundary({4.0, 9.0}, {"Scalar", "Scalar"})); plan.fill_same_level_and_physical(state, domain); @@ -153,6 +172,265 @@ TEST(test_prepared_boundary_plan, executes_same_level_and_component_physical_pro EXPECT_EQ(field(4, 2, 1), Real(16)); // 2*9 - interior(2) } +TEST(test_prepared_boundary_plan, + evaluates_prepared_coordinate_time_inflow_on_device_without_hot_path_allocation) { + const Box2D domain = Box2D::from_extents(4, 3); + const Geometry geometry(domain, Real(1), Real(5), Real(0), Real(3)); + MultiFab state = scalar_field(domain, 1, 1); + state.set_val(Real(-99)); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { values(i, j, 0) = Real(2); }); + } + PreparedBoundaryPlan plan("case::analytic::plan", 1, analytic_xlo_boundary()); + const auto lane = ExecutionLane::world("case::analytic::lane"); + auto session = plan.make_session(lane); + const runtime::multiblock::BoundaryEvaluationPoint point{"clock.analytic", 1, 0, 0, 0, + amr::Rational(0, 1), 0.1, 0.25}; + + EXPECT_THROW(session.fill_same_level_and_physical(state, domain), std::logic_error); + EXPECT_THROW(session.fill_same_level_and_physical( + state, geometry, + runtime::multiblock::BoundaryEvaluationPoint{"clock.other", 1, 0, 0, 0, + amr::Rational(0, 1), 0.1, 0.25}), + std::invalid_argument); + if (state.local_size() > 0) + EXPECT_EQ(state.fab(0)(-1, 1, 0), Real(-99)); + + session.fill_same_level_and_physical(state, geometry, point); + if (state.local_size() > 0) + EXPECT_EQ(state.fab(0)(-1, 1, 0), Real(3.5)); + const AllocationEventStats before = allocation_event_stats(); + session.fill_same_level_and_physical(state, geometry, point); + const AllocationEventStats after = allocation_event_stats(); + EXPECT_EQ(after, before); +} + +TEST(test_prepared_boundary_plan, analytic_inflow_preflights_nonfinite_values_before_any_mutation) { + const Box2D domain = Box2D::from_extents(4, 3); + const Geometry geometry(domain, Real(0), Real(4), Real(0), Real(3)); + const BoxArray boxes = BoxArray::from_domain(domain, 2); + MultiFab state(boxes, DistributionMapping(boxes.size(), n_ranks()), 1, 1); + state.set_val(Real(-99)); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { values(i, j, 0) = Real(2 + i + 10 * j); }); + } + device_fence(); + const MultiFab before = state; + PreparedBoundaryPlan plan("case::analytic::invalid-plan", 1, + analytic_xlo_boundary({"constant", "log"}, {-1.0, 0.0}, true)); + const auto lane = ExecutionLane::world("case::analytic::invalid-lane"); + auto session = plan.make_session(lane); + const runtime::multiblock::BoundaryEvaluationPoint point{"clock.analytic", 1, 0, 0, 0, + amr::Rational(0, 1), 0.1, 0.25}; + + EXPECT_THROW(session.fill_same_level_and_physical(state, geometry, point), std::runtime_error); + state.sync_host(); + before.sync_host(); + ASSERT_EQ(state.local_size(), before.local_size()); + for (int local = 0; local < state.local_size(); ++local) { + const Fab2D& observed = state.fab(local); + const Fab2D& expected = before.fab(local); + const Box2D grown = observed.grown_box(); + for (int j = grown.lo[1]; j <= grown.hi[1]; ++j) + for (int i = grown.lo[0]; i <= grown.hi[0]; ++i) + EXPECT_EQ(observed(i, j, 0), expected(i, j, 0)) + << "analytic refusal mutated local fab " << local << " at (" << i << ", " << j << ")"; + } +} + +TEST(test_prepared_boundary_plan, analytic_inflow_authenticates_one_clock_and_time_slot_per_plan) { + const auto face_types = + std::vector{"dirichlet", "foextrap", "dirichlet", "foextrap"}; + const auto face_values = std::vector(4, 0.0); + const auto face_identities = std::vector{ + "case::analytic::xlo", "case::analytic::xhi", "case::analytic::ylo", "case::analytic::yhi"}; + const auto roles = std::vector{"Scalar"}; + const auto opcodes = std::vector>{{"input"}, {}, {"input"}, {}}; + const auto literals = std::vector>{{0.0}, {}, {0.0}, {}}; + + EXPECT_THROW( + prepare_hyperbolic_boundary<2>(face_types, face_values, face_identities, roles, false, {}, {}, + opcodes, literals, {"clock.first", "", "clock.second", ""}), + std::invalid_argument); + EXPECT_THROW( + prepare_hyperbolic_boundary<2>({"dirichlet", "foextrap", "foextrap", "foextrap"}, face_values, + face_identities, roles, false, {}, {}, {{"input"}, {}, {}, {}}, + {{1.0}, {}, {}, {}}, {"clock.first", "", "", ""}), + std::invalid_argument); + auto ambiguous_values = face_values; + ambiguous_values[0] = 1.0; + EXPECT_THROW( + prepare_hyperbolic_boundary<2>({"dirichlet", "foextrap", "foextrap", "foextrap"}, + ambiguous_values, face_identities, roles, false, {}, {}, + {{"x"}, {}, {}, {}}, {{0.0}, {}, {}, {}}, {"", "", "", ""}), + std::invalid_argument); + EXPECT_THROW(prepare_hyperbolic_boundary<2>(face_types, face_values, face_identities, roles, + false, {}, {}, {{}, {}, {}, {}}, {{}, {}, {}, {}}, + {"clock.without-program", "", "", ""}), + std::invalid_argument); +} + +TEST(test_prepared_boundary_plan, + converts_primitive_fixed_state_once_before_conservative_face_execution) { + const Box2D domain = Box2D::from_extents(4, 4); + MultiFab state = scalar_field(domain, 4, 1); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { + for (int component = 0; component < 4; ++component) + values(i, j, component) = Real(1); + }); + } + std::vector face_values; + for (const double primitive : {2.0, 3.0, -1.0, 4.0}) + face_values.insert(face_values.end(), {0.0, primitive, 0.0, 0.0}); + auto boundary = prepare_hyperbolic_boundary<2>( + {"foextrap", "dirichlet", "foextrap", "foextrap"}, face_values, + {"case::fluid::xlo", "case::fluid::xhi", "case::fluid::ylo", "case::fluid::yhi"}, + {"Density", "MomentumX", "MomentumY", "Energy"}, false, + {"conservative", "primitive", "conservative", "conservative"}, + {"", "case::fluid::model-p2c", "", ""}); + PreparedBoundaryPlan plan("case::fluid::primitive-inflow", 1, std::move(boundary)); + const auto lane = ExecutionLane::world("case::fluid::primitive-inflow-lane"); + auto stale_session = plan.make_session(lane); + + EXPECT_TRUE(plan.requires_fixed_state_conversion()); + EXPECT_THROW(plan.fill_same_level_and_physical(state, domain), std::logic_error); + plan.prepare_fixed_state_conversion([](const double* primitive, double* conservative) { + constexpr double gamma = 1.4; + conservative[0] = primitive[0]; + conservative[1] = primitive[0] * primitive[1]; + conservative[2] = primitive[0] * primitive[2]; + conservative[3] = + primitive[3] / (gamma - 1.0) + + 0.5 * primitive[0] * (primitive[1] * primitive[1] + primitive[2] * primitive[2]); + }); + + EXPECT_FALSE(plan.requires_fixed_state_conversion()); + const auto& prepared_xhi = plan.hyperbolic_boundary().face(0, 1); + EXPECT_EQ(prepared_xhi.authored_representation, HyperbolicStateRepresentation::Primitive); + EXPECT_EQ(prepared_xhi.converter_identity, "case::fluid::model-p2c"); + EXPECT_TRUE(prepared_xhi.fixed_state_converted); + EXPECT_THROW(stale_session.fill_same_level_and_physical(state, domain), std::logic_error); + plan.fill_same_level_and_physical(state, domain); + const Fab2D& field = state.fab(0); + EXPECT_EQ(field(4, 2, 0), Real(3)); + EXPECT_EQ(field(4, 2, 1), Real(11)); + EXPECT_EQ(field(4, 2, 2), Real(-5)); + EXPECT_NEAR(field(4, 2, 3), Real(39), Real(1e-12)); +} + +TEST(test_prepared_boundary_plan, primitive_fixed_state_conversion_is_transactional_and_finite) { + auto make_plan = [] { + return PreparedBoundaryPlan( + "case::fluid::nonfinite-inflow", 1, + prepare_hyperbolic_boundary<2>( + {"foextrap", "dirichlet", "foextrap", "foextrap"}, {0.0, 2.0, 0.0, 0.0}, + {"case::fluid::xlo", "case::fluid::xhi", "case::fluid::ylo", "case::fluid::yhi"}, + {"Scalar"}, false, {"conservative", "primitive", "conservative", "conservative"}, + {"", "case::fluid::model-p2c", "", ""})); + }; + auto plan = make_plan(); + EXPECT_THROW(plan.prepare_fixed_state_conversion([](const double*, double* conservative) { + conservative[0] = std::numeric_limits::quiet_NaN(); + }), + std::runtime_error); + EXPECT_TRUE(plan.requires_fixed_state_conversion()); + + EXPECT_THROW(prepare_hyperbolic_boundary<2>( + {"foextrap", "foextrap", "foextrap", "foextrap"}, std::vector(4, 0.0), + {"case::xlo", "case::xhi", "case::ylo", "case::yhi"}, {"Scalar"}, false, + {"primitive", "conservative", "conservative", "conservative"}, + {"case::fluid::model-p2c", "", "", ""}), + std::invalid_argument); +} + +TEST(test_prepared_boundary_plan, + primitive_conversion_preserves_explicit_periodic_identification_validation) { + auto boundary = prepare_hyperbolic_boundary<2>( + {"periodic", "dirichlet", "foextrap", "periodic"}, {0.0, 2.0, 0.0, 0.0}, + {"case::fluid::xlo", "case::fluid::xhi", "case::fluid::ylo", "case::fluid::yhi"}, {"Scalar"}, + true, {"conservative", "primitive", "conservative", "conservative"}, + {"", "case::fluid::model-p2c", "", ""}); + + EXPECT_TRUE(boundary.requires_fixed_state_conversion()); + const auto converted = boundary.with_converted_fixed_states( + [](const double* primitive, double* conservative) { conservative[0] = primitive[0]; }); + EXPECT_FALSE(converted.requires_fixed_state_conversion()); +} + +TEST(test_prepared_boundary_plan, + model_aware_slip_wall_handles_multiple_normal_and_out_of_plane_components) { + const Box2D domain = Box2D::from_extents(4, 4); + MultiFab state = scalar_field(domain, 5, 2); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { + values(i, j, 0) = Real(1); + values(i, j, 1) = Real(2); + values(i, j, 2) = Real(5); + values(i, j, 3) = Real(3); + values(i, j, 4) = Real(4); + }); + } + auto boundary = prepare_hyperbolic_boundary<2>( + {"slip_wall", "slip_wall", "slip_wall", "slip_wall"}, std::vector(20, 0.0), + {"case::fluid::xlo", "case::fluid::xhi", "case::fluid::ylo", "case::fluid::yhi"}, + {"Density", "MomentumX", "MomentumX", "MomentumY", "AxialZ"}); + PreparedBoundaryPlan plan("case::fluid::slip-plan", 2, std::move(boundary)); + + plan.fill_same_level_and_physical(state, domain); + + const Fab2D& field = state.fab(0); + EXPECT_EQ(field(-1, 2, 0), Real(1)); + EXPECT_EQ(field(-1, 2, 1), Real(-2)); + EXPECT_EQ(field(-1, 2, 2), Real(-5)); + EXPECT_EQ(field(-1, 2, 3), Real(3)); + EXPECT_EQ(field(-1, 2, 4), Real(-4)); + EXPECT_EQ(field(-2, 2, 1), Real(-2)); + EXPECT_EQ(field(2, -1, 1), Real(2)); + EXPECT_EQ(field(2, -1, 2), Real(5)); + EXPECT_EQ(field(2, -1, 3), Real(-3)); + EXPECT_EQ(field(2, -1, 4), Real(-4)); + EXPECT_EQ(field(2, -2, 3), Real(-3)); +} + +TEST(test_prepared_boundary_plan, polar_and_axial_reflections_are_distinct_in_1d_2d_3d_frames) { + const auto polar_1d = HyperbolicComponentTransform<1>::polar_vector(0); + EXPECT_EQ(polar_1d.reflection_sign(0), Real(-1)); + + const auto polar_normal_2d = HyperbolicComponentTransform<2>::polar_vector(0); + const auto polar_tangent_2d = HyperbolicComponentTransform<2>::polar_vector(1); + const auto polar_out_of_plane_2d = HyperbolicComponentTransform<2>::polar_vector(2); + const auto axial_normal_2d = HyperbolicComponentTransform<2>::axial_vector(0); + const auto axial_tangent_2d = HyperbolicComponentTransform<2>::axial_vector(1); + const auto axial_out_of_plane_2d = HyperbolicComponentTransform<2>::axial_vector(2); + EXPECT_EQ(polar_normal_2d.reflection_sign(0), Real(-1)); + EXPECT_EQ(polar_tangent_2d.reflection_sign(0), Real(1)); + EXPECT_EQ(polar_out_of_plane_2d.reflection_sign(0), Real(1)); + EXPECT_EQ(polar_out_of_plane_2d.reflection_sign(1), Real(1)); + EXPECT_EQ(axial_normal_2d.reflection_sign(0), Real(1)); + EXPECT_EQ(axial_tangent_2d.reflection_sign(0), Real(-1)); + EXPECT_EQ(axial_out_of_plane_2d.reflection_sign(0), Real(-1)); + EXPECT_EQ(axial_out_of_plane_2d.reflection_sign(1), Real(-1)); + + const auto polar_z_3d = HyperbolicComponentTransform<3>::polar_vector(2); + const auto axial_z_3d = HyperbolicComponentTransform<3>::axial_vector(2); + EXPECT_EQ(polar_z_3d.reflection_sign(0), Real(1)); + EXPECT_EQ(axial_z_3d.reflection_sign(0), Real(-1)); + EXPECT_EQ(polar_z_3d.reflection_sign(2), Real(-1)); + EXPECT_EQ(axial_z_3d.reflection_sign(2), Real(1)); +} + +TEST(test_prepared_boundary_plan, slip_wall_fails_without_declared_normal_polar_role) { + EXPECT_THROW(prepare_hyperbolic_boundary<2>( + {"slip_wall", "slip_wall", "foextrap", "foextrap"}, std::vector(8, 0.0), + {"case::xlo", "case::xhi", "case::ylo", "case::yhi"}, {"Density", "MomentumY"}), + std::invalid_argument); +} + TEST(test_prepared_boundary_plan, materializes_move_only_lane_session_before_execution) { static_assert(!std::is_copy_constructible_v); static_assert(!std::is_copy_assignable_v); @@ -164,7 +442,7 @@ TEST(test_prepared_boundary_plan, materializes_move_only_lane_session_before_exe Array4 values = state.fab(local).array(); for_each_cell(state.box(local), [=](int i, int j) { values(i, j, 0) = Real(3); }); } - PreparedBoundaryPlan plan("case::block::session-plan", 1, {physical_bc()}); + PreparedBoundaryPlan plan("case::block::session-plan", 1, physical_boundary()); const auto lane = ExecutionLane::world("case::block::session-lane"); auto original = plan.make_session(lane); auto session = std::move(original); @@ -175,65 +453,22 @@ TEST(test_prepared_boundary_plan, materializes_move_only_lane_session_before_exe EXPECT_EQ(state.fab(0)(4, 2, 0), Real(5)); } -TEST(test_prepared_boundary_plan, grid_sessions_apply_robin_with_each_level_geometry) { - const Box2D coarse_domain = Box2D::from_extents(2, 2); - const Box2D fine_domain = Box2D::from_extents(4, 4); - MultiFab coarse = scalar_field(coarse_domain, 1, 1); - MultiFab fine = scalar_field(fine_domain, 1, 1); - coarse.set_val(Real(2)); - fine.set_val(Real(2)); - - BCRec robin; - robin.xlo = BCType::Robin; - robin.xhi = BCType::Foextrap; - robin.ylo = BCType::Foextrap; - robin.yhi = BCType::Foextrap; - robin.xlo_alpha = Real(1); - robin.xlo_beta = Real(1); - robin.xlo_val = Real(0); - robin.dx = Real(37); // Deliberately not either level metric. - auto plan = std::make_shared("case::block::robin-plan", 1, - std::vector{robin}); - - // A Box2D has no physical metric. Keeping the historical overload for metric-independent laws - // is harmless, but Robin must never reuse the declaration-time placeholder spacing. - EXPECT_THROW(plan->fill_same_level_and_physical(coarse, coarse_domain), std::invalid_argument); - const auto metricless_lane = ExecutionLane::world("case::block::robin-metricless-lane"); - auto metricless_session = plan->make_session(metricless_lane); - EXPECT_THROW(metricless_session.fill_same_level_and_physical(coarse, coarse_domain), +TEST(test_prepared_boundary_plan, rejects_field_only_robin_as_transport_semantics) { + EXPECT_THROW(prepare_hyperbolic_boundary<2>( + {"robin", "foextrap", "foextrap", "foextrap"}, {0.0, 0.0, 0.0, 0.0}, + {"case::xlo", "case::xhi", "case::ylo", "case::yhi"}, {"Scalar"}), std::invalid_argument); - - GridContext coarse_context; - coarse_context.dom = coarse_domain; - coarse_context.geom = Geometry(coarse_domain, Real(0), Real(1), Real(0), Real(1)); - coarse_context.boundary_plan = plan; - GridContext fine_context; - fine_context.dom = fine_domain; - fine_context.geom = Geometry(fine_domain, Real(0), Real(1), Real(0), Real(1)); - fine_context.boundary_plan = plan; - - const auto coarse_lane = ExecutionLane::world("case::block::robin-coarse-lane"); - const auto fine_lane = ExecutionLane::world("case::block::robin-fine-lane"); - PreparedGridBoundarySession coarse_session(coarse_context, coarse_lane); - PreparedGridBoundarySession fine_session(fine_context, fine_lane); - coarse_session.fill(coarse); - fine_session.fill(fine); - - // alpha=beta=1, value=0 gives u_g=((1/h)-1/2)/((1/h)+1/2) u_i. - EXPECT_EQ(plan->component_bc(0).dx, Real(37)); // Execution did not mutate shared authority. - EXPECT_NEAR(coarse.fab(0)(-1, 0, 0), Real(1.2), 1e-12); // h = 1/2 - EXPECT_NEAR(fine.fab(0)(-1, 0, 0), Real(14) / Real(9), 1e-12); // h = 1/4 } TEST(test_prepared_boundary_plan, rejects_incomplete_periodic_pairs_and_insufficient_ghosts) { - BCRec mixed = physical_bc(); - mixed.xlo = BCType::Periodic; - EXPECT_THROW(PreparedBoundaryPlan("case::bad-periodic::ghost-plan", 1, {mixed}), - std::runtime_error); + EXPECT_THROW(prepare_hyperbolic_boundary<2>( + {"periodic", "foextrap", "foextrap", "foextrap"}, {0.0, 0.0, 0.0, 0.0}, + {"case::xlo", "case::xhi", "case::ylo", "case::yhi"}, {"Scalar"}), + std::invalid_argument); const Box2D domain = Box2D::from_extents(2, 2); MultiFab state = scalar_field(domain, 1, 1); - PreparedBoundaryPlan deep("case::deep::ghost-plan", 2, {physical_bc()}); + PreparedBoundaryPlan deep("case::deep::ghost-plan", 2, physical_boundary()); EXPECT_THROW(deep.fill_same_level_and_physical(state, domain), std::runtime_error); } @@ -247,7 +482,7 @@ TEST(test_prepared_boundary_plan, executes_reflected_periodic_ghosts_on_a_multib } const PeriodicIdentification2D reflected_x{0, 1, std::array{{0, 1}}, std::array{{1, -1}}}; - PreparedBoundaryPlan plan("case::block::reflected-x", 1, {reflected_x_periodic_bc()}, {}, "", {}, + PreparedBoundaryPlan plan("case::block::reflected-x", 1, periodic_boundary({}, true), {}, "", {}, {reflected_x}); plan.fill_same_level_and_physical(state, domain); @@ -288,9 +523,9 @@ TEST(test_prepared_boundary_plan, explicit_identity_periodicity_keeps_the_legacy } const PeriodicIdentification2D identity{0, 1, std::array{{0, 1}}, std::array{{1, 1}}}; - PreparedBoundaryPlan legacy_plan("case::block::legacy-periodic", 1, {reflected_x_periodic_bc()}); + PreparedBoundaryPlan legacy_plan("case::block::legacy-periodic", 1, periodic_boundary()); PreparedBoundaryPlan explicit_plan("case::block::explicit-identity-periodic", 1, - {reflected_x_periodic_bc()}, {}, "", {}, {identity}); + periodic_boundary({}, true), {}, "", {}, {identity}); legacy_plan.fill_same_level_and_physical(legacy, domain); explicit_plan.fill_same_level_and_physical(explicit_identity, domain); @@ -306,29 +541,78 @@ TEST(test_prepared_boundary_plan, explicit_identity_periodicity_keeps_the_legacy } TEST(test_prepared_boundary_plan, axis_permutation_refuses_incompatible_rectangular_geometry) { - BCRec rotated; - rotated.xlo = BCType::Periodic; - rotated.xhi = BCType::Foextrap; - rotated.ylo = BCType::Foextrap; - rotated.yhi = BCType::Periodic; const PeriodicIdentification2D xlo_to_yhi{0, 3, std::array{{1, 0}}, std::array{{1, 1}}}; - PreparedBoundaryPlan plan("case::block::rotated-periodic", 1, {rotated}, {}, "", {}, - {xlo_to_yhi}); + PreparedBoundaryPlan plan( + "case::block::rotated-periodic", 1, + periodic_boundary({"periodic", "foextrap", "foextrap", "periodic"}, true), {}, "", {}, + {xlo_to_yhi}); const Box2D rectangular_domain = Box2D::from_extents(8, 6); MultiFab state = scalar_field(rectangular_domain, 1, 1); EXPECT_THROW(plan.fill_same_level_and_physical(state, rectangular_domain), std::invalid_argument); } +TEST(test_prepared_boundary_plan, mapped_periodicity_refuses_unmapped_vector_components) { + const PeriodicIdentification2D xlo_to_yhi{0, 3, std::array{{1, 0}}, + std::array{{1, 1}}}; + auto vector_boundary = prepare_hyperbolic_boundary<2>( + {"periodic", "foextrap", "foextrap", "periodic"}, std::vector(8, 0.0), + {"case::vector::xlo", "case::vector::xhi", "case::vector::ylo", "case::vector::yhi"}, + {"Density", "MomentumX"}, true); + EXPECT_THROW(PreparedBoundaryPlan("case::block::rotated-vector-periodic", 1, + std::move(vector_boundary), {}, "", {}, {xlo_to_yhi}), + std::runtime_error); +} + +TEST(test_prepared_boundary_plan, mapped_periodicity_refuses_unmapped_analytic_coordinates) { + const PeriodicIdentification2D xlo_to_yhi{0, 3, std::array{{1, 0}}, + std::array{{1, 1}}}; + auto analytic_boundary = prepare_hyperbolic_boundary<2>( + {"periodic", "dirichlet", "foextrap", "periodic"}, std::vector(4, 0.0), + {"case::analytic::xlo", "case::analytic::xhi", "case::analytic::ylo", "case::analytic::yhi"}, + {"Scalar"}, true, {}, {}, {{}, {"x"}, {}, {}}, {{}, {0.0}, {}, {}}, {"", "", "", ""}); + + EXPECT_THROW(PreparedBoundaryPlan("case::block::rotated-analytic-periodic", 1, + std::move(analytic_boundary), {}, "", {}, {xlo_to_yhi}), + std::runtime_error); +} + +TEST(test_prepared_boundary_plan, axis_permutation_executes_on_a_square_domain) { + const PeriodicIdentification2D xlo_to_yhi{0, 3, std::array{{1, 0}}, + std::array{{1, 1}}}; + PreparedBoundaryPlan plan( + "case::block::rotated-periodic-square", 1, + periodic_boundary({"periodic", "foextrap", "foextrap", "periodic"}, true), {}, "", {}, + {xlo_to_yhi}); + const Box2D domain = Box2D::from_extents(6, 6); + MultiFab state = scalar_field(domain, 1, 1); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { values(i, j, 0) = Real(i + 100 * j); }); + } + + EXPECT_NO_THROW(plan.fill_same_level_and_physical(state, domain)); + for (int local = 0; local < state.local_size(); ++local) { + const Fab2D& field = state.fab(local); + const Box2D grown = field.grown_box(); + for (int j = domain.lo[1]; j <= domain.hi[1]; ++j) + if (grown.contains(domain.lo[0] - 1, j)) + EXPECT_EQ(field(domain.lo[0] - 1, j, 0), Real(j + 100 * domain.hi[1])); + for (int i = domain.lo[0]; i <= domain.hi[0]; ++i) + if (grown.contains(i, domain.hi[1] + 1)) + EXPECT_EQ(field(i, domain.hi[1] + 1, 0), Real(domain.lo[0] + 100 * i)); + } +} + TEST(test_prepared_boundary_plan, grid_context_routes_exact_nary_storage_registry) { const Box2D domain = Box2D::from_extents(3, 3); MultiFab primary = scalar_field(domain, 1, 1); MultiFab coupled = scalar_field(domain, 2, 1); MultiFab auxiliary = scalar_field(domain, 3, 1); MultiFab output = scalar_field(domain, 1, 0); - auto plan = std::make_shared("case::nary::ghost-plan", 1, - std::vector{physical_bc()}); + auto plan = + std::make_shared("case::nary::ghost-plan", 1, physical_boundary()); GridContext context; context.dom = domain; context.geom = Geometry(domain, Real(0), Real(1), Real(0), Real(1)); diff --git a/tests/cpp/unit/numerics/test_flux_interfaces.cpp b/tests/cpp/unit/numerics/test_flux_interfaces.cpp index 7c3d7a211..4191d5758 100644 --- a/tests/cpp/unit/numerics/test_flux_interfaces.cpp +++ b/tests/cpp/unit/numerics/test_flux_interfaces.cpp @@ -3,11 +3,16 @@ #include #include #include +#include +#include #include +#include #include #include #include +#include +#include namespace { @@ -25,6 +30,62 @@ struct Advect { struct OtherAdvect : Advect {}; +struct NonFiniteRoeAdvect : Advect { + POPS_HD State roe_dissipation(const State&, const Aux&, const State&, const Aux&, int) const { + return State{std::numeric_limits::quiet_NaN()}; + } +}; + +struct NonFiniteRoeFluxAdvect : Advect { + POPS_HD State flux(const State&, const Aux&, int) const { + return State{std::numeric_limits::quiet_NaN()}; + } + POPS_HD State roe_dissipation(const State&, const Aux&, const State&, const Aux&, int) const { + return State{}; + } +}; + +enum class HllcFailureSite { kPhysicalFlux, kPressure, kContact, kStarState, kFinalFlux }; + +struct SelectiveInvalidHllc { + using State = pops::StateVec<1>; + using Aux = pops::Aux; + static constexpr int n_vars = 1; + + HllcFailureSite failure_site; + + POPS_HD State flux(const State& state, const Aux&, int) const { + return failure_site == HllcFailureSite::kPhysicalFlux + ? State{std::numeric_limits::quiet_NaN()} + : state; + } + POPS_HD pops::Real max_wave_speed(const State&, const Aux&, int) const { return pops::Real(1); } + POPS_HD void wave_speeds(const State&, const Aux&, int, pops::Real& lower, + pops::Real& upper) const { + const pops::Real magnitude = failure_site == HllcFailureSite::kFinalFlux + ? std::numeric_limits::max() + : pops::Real(1); + lower = -magnitude; + upper = magnitude; + } + POPS_HD pops::Real pressure(const State&) const { + return failure_site == HllcFailureSite::kPressure ? std::numeric_limits::quiet_NaN() + : pops::Real(1); + } + POPS_HD pops::Real contact_speed(const State&, const State&, pops::Real, pops::Real, pops::Real, + pops::Real, int) const { + return failure_site == HllcFailureSite::kContact ? std::numeric_limits::quiet_NaN() + : pops::Real(0); + } + POPS_HD State hllc_star_state(const State& state, pops::Real, pops::Real, pops::Real, int) const { + if (failure_site == HllcFailureSite::kStarState) + return State{std::numeric_limits::quiet_NaN()}; + if (failure_site == HllcFailureSite::kFinalFlux) + return State{std::numeric_limits::max()}; + return state; + } +}; + struct SelectiveInvalidAdvect { using State = pops::StateVec<1>; using Aux = pops::Aux; @@ -68,6 +129,49 @@ struct ProviderStorage { } }; +struct QualifiedProviderAdvect : ProviderAdvect { + static constexpr int n_flux_providers = 1; + inline static constexpr std::array + flux_provider_requirements{{ + {"model::qualified", "field", "electric", "grad_x", "scalar", "cell", "", + "layout::primary", "", "field::electric", true, 1}, + }}; +}; + +struct UnavailableQualifiedProviderAdvect : ProviderAdvect { + static constexpr int n_flux_providers = 1; + inline static constexpr std::array + flux_provider_requirements{{ + {"model::unavailable", "field", "electric", "grad_x", "scalar", "cell", "", + "layout::primary", "", "field::electric", false, 1}, + }}; +}; + +struct IncompleteQualifiedProviderAdvect : ProviderAdvect { + static constexpr int n_flux_providers = 1; +}; + +struct DuplicateQualifiedProviderAdvect : ProviderAdvect { + static constexpr int n_flux_providers = 2; + inline static constexpr std::array + flux_provider_requirements{{ + {"model::duplicate", "field", "electric", "grad_x", "scalar", "cell", "", + "layout::primary", "", "field::electric", true, 1}, + {"model::duplicate", "field", "magnetic", "grad_x", "scalar", "cell", "", + "layout::primary", "", "field::magnetic", true, 1}, + }}; +}; + +struct CountingProviderStorage { + pops::Real values[3]{pops::Real(11), pops::Real(4), pops::Real(13)}; + mutable int reads[3]{}; + + POPS_HD pops::Real operator()(int, int, int component) const { + ++reads[component]; + return values[component]; + } +}; + template auto providers(std::initializer_list values = {}) { pops::FluxProviderValues resolved{}; @@ -109,6 +213,22 @@ struct RecordFatalFluxFailures { } }; +struct NonFinitePrimitiveModel { + using State = pops::StateVec<1>; + using Prim = pops::StateVec<1>; + using Aux = pops::Aux; + static constexpr int n_vars = 1; + + POPS_HD State flux(const State& state, const Aux&, int) const { return state; } + POPS_HD pops::Real max_wave_speed(const State&, const Aux&, int) const { return pops::Real(1); } + POPS_HD State source(const State&, const Aux&) const { return {}; } + POPS_HD pops::Real elliptic_rhs(const State&) const { return pops::Real(0); } + POPS_HD Prim to_primitive(const State&) const { + return Prim{std::numeric_limits::quiet_NaN()}; + } + POPS_HD State to_conservative(const Prim& primitive) const { return primitive; } +}; + } // namespace TEST(test_flux_interfaces, equal_state_consistency_and_declared_stability) { @@ -253,6 +373,30 @@ TEST(test_flux_interfaces, provider_pack_is_model_qualified_and_failure_action_i pops::TransactionFailureAction::kAbortRun); } +TEST(test_flux_interfaces, generated_provider_requirements_own_native_slot_reads) { + static_assert(pops::has_qualified_flux_provider_requirements); + static_assert(pops::qualified_flux_provider_requirements_valid()); + static_assert( + !pops::qualified_flux_provider_requirements_valid()); + static_assert( + !pops::qualified_flux_provider_requirements_valid()); + static_assert( + !pops::qualified_flux_provider_requirements_valid()); + + const CountingProviderStorage storage{}; + const auto bound = pops::bind_flux_providers_at(storage, 0, 0); + EXPECT_EQ(storage.reads[0], 0); + EXPECT_EQ(storage.reads[1], 1); + EXPECT_EQ(storage.reads[2], 0); + + const QualifiedProviderAdvect::State state{pops::Real(3)}; + const auto trace = pops::make_face_trace(state, bound); + const auto density = + pops::PhysicalFluxView{QualifiedProviderAdvect{}}.evaluate( + trace, pops::FaceContext::axis_aligned(0)); + EXPECT_DOUBLE_EQ(density.value[0], pops::Real(12)); +} + TEST(test_flux_interfaces, failed_evaluation_never_publishes_a_density) { const Advect physical{}; const Advect::State state{pops::Real(3)}; @@ -267,6 +411,58 @@ TEST(test_flux_interfaces, failed_evaluation_never_publishes_a_density) { EXPECT_TRUE(std::isnan(evaluation.checked_density().value[0])); } +TEST(test_flux_interfaces, roe_rejects_nonfinite_dissipation_with_a_typed_cause) { + const NonFiniteRoeAdvect physical{}; + const NonFiniteRoeAdvect::State left{pops::Real(1)}, right{pops::Real(2)}; + const auto bound = providers(); + const auto evaluation = pops::evaluate_numerical_flux( + pops::RoeFlux{}, physical, left, bound, right, bound, pops::FaceContext::axis_aligned(0)); + + EXPECT_EQ(evaluation.status, pops::EvaluationStatus::kReject); + EXPECT_EQ(evaluation.failure_action(), pops::TransactionFailureAction::kRejectStep); + EXPECT_EQ(evaluation.reason_code, + pops::riemann_reason_code(pops::RiemannFailureCause::kRoeNonFiniteDissipation)); + EXPECT_TRUE(std::isnan(evaluation.checked_density().value[0])); + + const NonFiniteRoeFluxAdvect invalid_flux{}; + const auto invalid_flux_bound = providers(); + const auto flux_evaluation = pops::evaluate_numerical_flux( + pops::RoeFlux{}, invalid_flux, NonFiniteRoeFluxAdvect::State{pops::Real(1)}, + invalid_flux_bound, NonFiniteRoeFluxAdvect::State{pops::Real(2)}, invalid_flux_bound, + pops::FaceContext::axis_aligned(0)); + EXPECT_EQ(flux_evaluation.status, pops::EvaluationStatus::kReject); + EXPECT_EQ(flux_evaluation.reason_code, + pops::riemann_reason_code(pops::RiemannFailureCause::kRoeNonFiniteFlux)); + EXPECT_TRUE(std::isnan(flux_evaluation.checked_density().value[0])); +} + +TEST(test_flux_interfaces, hllc_rejects_each_nonfinite_provider_stage_with_a_typed_cause) { + struct ExpectedFailure { + HllcFailureSite site; + pops::RiemannFailureCause cause; + }; + const ExpectedFailure expected[] = { + {HllcFailureSite::kPhysicalFlux, pops::RiemannFailureCause::kHllcNonFinitePhysicalFlux}, + {HllcFailureSite::kPressure, pops::RiemannFailureCause::kHllcNonFinitePressure}, + {HllcFailureSite::kContact, pops::RiemannFailureCause::kHllcNonFiniteContact}, + {HllcFailureSite::kStarState, pops::RiemannFailureCause::kHllcNonFiniteStarState}, + {HllcFailureSite::kFinalFlux, pops::RiemannFailureCause::kHllcNonFiniteFlux}, + }; + + for (const auto& failure : expected) { + const SelectiveInvalidHllc physical{failure.site}; + const auto bound = providers(); + const auto evaluation = pops::evaluate_numerical_flux( + pops::HLLCFlux{}, physical, SelectiveInvalidHllc::State{pops::Real(1)}, bound, + SelectiveInvalidHllc::State{pops::Real(2)}, bound, pops::FaceContext::axis_aligned(0)); + + EXPECT_EQ(evaluation.status, pops::EvaluationStatus::kReject); + EXPECT_EQ(evaluation.failure_action(), pops::TransactionFailureAction::kRejectStep); + EXPECT_EQ(evaluation.reason_code, pops::riemann_reason_code(failure.cause)); + EXPECT_TRUE(std::isnan(evaluation.checked_density().value[0])); + } +} + TEST(test_flux_interfaces, device_failure_reduction_orders_status_then_reason_deterministically) { static_assert(std::is_trivially_copyable_v); static_assert(sizeof(pops::FluxEvaluationTracker) == sizeof(std::uint64_t)); @@ -297,6 +493,68 @@ TEST(test_flux_interfaces, fatal_flux_failure_remains_typed_and_preserves_reason FAIL() << "fatal device flux failure was not propagated as FluxEvaluationFailure"; } +TEST(test_flux_interfaces, recovery_report_uses_the_flux_failure_reduction_without_type_erasure) { + pops::RecoveryReport recovery; + recovery.status = pops::RecoveryStatus::kRejected; + recovery.cause = pops::RecoveryCause::kExplicitRejection; + recovery.reason_code = 0x755u; + + std::uint64_t packed = 0; + pops::FluxEvaluationTracker tracker{pops::process_world_flux_collective}; + tracker.recorder().record_recovery(recovery, packed); + tracker.merge(packed); + + const pops::FluxFailureReport report = tracker.collective_report(); + EXPECT_EQ(report.status, pops::EvaluationStatus::kReject); + EXPECT_EQ(report.reason_code, 0x755u); + EXPECT_EQ(report.action(), pops::TransactionFailureAction::kRejectStep); +} + +TEST(test_flux_interfaces, face_recovery_refusal_never_reaches_the_numerical_flux) { + static_assert( + std::is_trivially_copyable_v>); + static_assert( + std::is_trivially_copyable_v>); + const pops::Box2D domain = pops::Box2D::from_extents(4, 4); + const pops::BoxArray cells(std::vector{domain}); + const pops::DistributionMapping distribution(1, pops::n_ranks()); + pops::MultiFab state(cells, distribution, 1, 2); + pops::MultiFab providers_field(cells, distribution, pops::kAuxBaseComps, 2); + state.set_val(pops::Real(1)); + providers_field.set_val(pops::Real(0)); + + const auto local_state = state.fab(0).const_array(); + const auto reconstructed = pops::reconstruct_pp_recovered( + NonFinitePrimitiveModel{}, local_state, domain.lo[0] + 1, domain.lo[1] + 1, 0, pops::Real(1), + pops::Minmod{}, true, pops::Real(0), 0); + ASSERT_FALSE(reconstructed.publication_permitted()); + EXPECT_EQ(reconstructed.recovery.status, pops::RecoveryStatus::kInvalidContract); + EXPECT_EQ(reconstructed.recovery.cause, pops::RecoveryCause::kNonFiniteCandidate); + EXPECT_EQ(reconstructed.value[0], pops::Real(1)); + const auto value_only = pops::reconstruct_pp( + NonFinitePrimitiveModel{}, local_state, domain.lo[0] + 1, domain.lo[1] + 1, 0, pops::Real(1), + pops::Minmod{}, true, pops::Real(0), 0); + EXPECT_TRUE(std::isnan(value_only[0])); + + std::vector x_faces{pops::xface_box(domain)}; + std::vector y_faces{pops::yface_box(domain)}; + pops::MultiFab flux_x(pops::BoxArray(std::move(x_faces)), distribution, 1, 0); + pops::MultiFab flux_y(pops::BoxArray(std::move(y_faces)), distribution, 1, 0); + try { + pops::compute_face_fluxes(NonFinitePrimitiveModel{}, state, + providers_field, flux_x, flux_y, + pops::Real(1), pops::Real(1), true); + } catch (const pops::FluxEvaluationFailure& failure) { + EXPECT_EQ(failure.status(), pops::EvaluationStatus::kFailed); + EXPECT_EQ(failure.reason_code(), + pops::detail::kVariableRecoveryReasonBase | + static_cast(pops::RecoveryCause::kNonFiniteCandidate)); + EXPECT_EQ(failure.phase(), "compute_face_fluxes"); + return; + } + FAIL() << "a refused primitive recovery reached or escaped the face-flux path"; +} + TEST(test_flux_interfaces, native_storage_binds_only_the_exact_model_pack) { const ProviderAdvect physical{}; const ProviderAdvect::State state{pops::Real(3)}; diff --git a/tests/cpp/unit/numerics/test_prepared_numerics_gate.cpp b/tests/cpp/unit/numerics/test_prepared_numerics_gate.cpp new file mode 100644 index 000000000..d262457f7 --- /dev/null +++ b/tests/cpp/unit/numerics/test_prepared_numerics_gate.cpp @@ -0,0 +1,276 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include + +#if defined(_MSC_VER) +#include +#endif + +namespace { + +std::atomic g_heap_allocations{0}; + +void* counted_allocate(std::size_t size) { + void* pointer = std::malloc(size == 0 ? 1 : size); + if (pointer == nullptr) + throw std::bad_alloc(); + g_heap_allocations.fetch_add(1, std::memory_order_relaxed); + return pointer; +} + +void* counted_aligned_allocate(std::size_t size, std::size_t alignment) { + void* pointer = nullptr; +#if defined(_MSC_VER) + pointer = _aligned_malloc(size == 0 ? 1 : size, alignment); +#else + if (posix_memalign(&pointer, alignment, size == 0 ? 1 : size) != 0) + pointer = nullptr; +#endif + if (pointer == nullptr) + throw std::bad_alloc(); + g_heap_allocations.fetch_add(1, std::memory_order_relaxed); + return pointer; +} + +void counted_aligned_free(void* pointer) noexcept { +#if defined(_MSC_VER) + _aligned_free(pointer); +#else + std::free(pointer); +#endif +} + +using pops::Real; + +struct PositiveCandidate { + POPS_HD bool operator()(const Real (&value)[1], int* component = nullptr) const { + const bool accepted = value[0] > Real(0); + if (component != nullptr) + *component = accepted ? -1 : 0; + return accepted; + } +}; + +struct SquareResidual { + Real target = 0; + + POPS_HD pops::LocalNonlinearEvaluationResult operator()(const Real (&value)[1], + Real (&residual)[1]) const { + residual[0] = value[0] * value[0] - target; + return pops::LocalNonlinearEvaluationResult::ok(); + } +}; + +struct SquareProblemFactory { + POPS_HD auto operator()(const Real (&conserved)[1]) const { + pops::PreparedLocalNonlinearControls controls; + controls.max_iterations = 16; + controls.absolute_tolerance = Real(1e-13); + return pops::prepare_local_nonlinear_problem<1>(SquareResidual{conserved[0]}, + pops::FiniteDifferenceLocalJacobian<1>{}, + PositiveCandidate{}, controls); + } +}; + +struct RejectingResidual { + POPS_HD pops::LocalNonlinearEvaluationResult operator()(const Real (&)[1], Real (&)[1]) const { + return pops::LocalNonlinearEvaluationResult::reject(731); + } +}; + +struct RejectingProblemFactory { + POPS_HD auto operator()(const Real (&)[1]) const { + pops::PreparedLocalNonlinearControls controls; + return pops::prepare_local_nonlinear_problem<1>(RejectingResidual{}, + pops::FiniteDifferenceLocalJacobian<1>{}, + PositiveCandidate{}, controls); + } +}; + +} // namespace + +void* operator new(std::size_t size) { + return counted_allocate(size); +} + +void* operator new[](std::size_t size) { + return counted_allocate(size); +} + +void operator delete(void* pointer) noexcept { + std::free(pointer); +} + +void operator delete[](void* pointer) noexcept { + std::free(pointer); +} + +void operator delete(void* pointer, std::size_t) noexcept { + std::free(pointer); +} + +void operator delete[](void* pointer, std::size_t) noexcept { + std::free(pointer); +} + +void* operator new(std::size_t size, const std::nothrow_t&) noexcept { + try { + return counted_allocate(size); + } catch (...) { + return nullptr; + } +} + +void* operator new[](std::size_t size, const std::nothrow_t&) noexcept { + try { + return counted_allocate(size); + } catch (...) { + return nullptr; + } +} + +void operator delete(void* pointer, const std::nothrow_t&) noexcept { + std::free(pointer); +} + +void operator delete[](void* pointer, const std::nothrow_t&) noexcept { + std::free(pointer); +} + +void* operator new(std::size_t size, std::align_val_t alignment) { + return counted_aligned_allocate(size, static_cast(alignment)); +} + +void* operator new[](std::size_t size, std::align_val_t alignment) { + return counted_aligned_allocate(size, static_cast(alignment)); +} + +void operator delete(void* pointer, std::align_val_t) noexcept { + counted_aligned_free(pointer); +} + +void operator delete[](void* pointer, std::align_val_t) noexcept { + counted_aligned_free(pointer); +} + +void operator delete(void* pointer, std::size_t, std::align_val_t) noexcept { + counted_aligned_free(pointer); +} + +void operator delete[](void* pointer, std::size_t, std::align_val_t) noexcept { + counted_aligned_free(pointer); +} + +void* operator new(std::size_t size, std::align_val_t alignment, const std::nothrow_t&) noexcept { + try { + return counted_aligned_allocate(size, static_cast(alignment)); + } catch (...) { + return nullptr; + } +} + +void* operator new[](std::size_t size, std::align_val_t alignment, const std::nothrow_t&) noexcept { + try { + return counted_aligned_allocate(size, static_cast(alignment)); + } catch (...) { + return nullptr; + } +} + +void operator delete(void* pointer, std::align_val_t, const std::nothrow_t&) noexcept { + counted_aligned_free(pointer); +} + +void operator delete[](void* pointer, std::align_val_t, const std::nothrow_t&) noexcept { + counted_aligned_free(pointer); +} + +TEST(PreparedNumericsGate, AllocationProbeDetectsControlHeapTraffic) { + const std::uint64_t before = g_heap_allocations.load(std::memory_order_relaxed); + void* ordinary = ::operator new(32); + void* aligned = ::operator new(64, std::align_val_t{64}); + const std::uint64_t after = g_heap_allocations.load(std::memory_order_relaxed); + ::operator delete(ordinary); + ::operator delete(aligned, std::align_val_t{64}); + + EXPECT_EQ(after - before, std::uint64_t{2}); +} + +TEST(PreparedNumericsGate, ConvergedPreparedPathAllocatesNothingAndRollsBack) { + const Real conserved[1] = {Real(4)}; + const Real initial[1] = {Real(1)}; + const auto problem = SquareProblemFactory{}(conserved); + const auto methods = + pops::recovery_methods(pops::prepared_local_nonlinear_recovery<1>(SquareProblemFactory{})); + const auto plan = pops::prepare_variable_recovery<1>(PositiveCandidate{}, methods); + static_assert(std::is_trivially_copyable_v); + static_assert(std::is_trivially_copyable_v); + + Real accepted[1] = {Real(9)}; + pops::RecoveryWarmStartSlot<1> cache; + const Real cached[1] = {Real(8)}; + cache.store(cached, 3, 7); + pops::RecoveryPublicationTransaction<1> transaction(accepted, cache); + + const std::uint64_t before = g_heap_allocations.load(std::memory_order_relaxed); + const auto local = pops::solve_prepared_local_nonlinear(problem, initial); + const auto recovered = pops::recover_prepared_variable(plan, conserved, initial); + const bool published = transaction.publish_tentative(recovered, 4, 8); + const bool rolled_back = transaction.rollback(); + const std::uint64_t after = g_heap_allocations.load(std::memory_order_relaxed); + + ASSERT_TRUE(local.solved()); + ASSERT_TRUE(recovered.recovered()); + EXPECT_TRUE(published); + EXPECT_TRUE(rolled_back); + EXPECT_EQ(after, before); + EXPECT_NEAR(local.value[0], Real(2), Real(1e-10)); + EXPECT_NEAR(recovered.value[0], Real(2), Real(1e-10)); + EXPECT_EQ(accepted[0], Real(9)); + EXPECT_EQ(cache.value[0], Real(8)); + EXPECT_EQ(cache.topology_generation, std::uint64_t{3}); + EXPECT_EQ(cache.state_generation, std::uint64_t{7}); +} + +TEST(PreparedNumericsGate, FatalEvaluationIsTypedAllocationFreeAndCannotPublish) { + const Real conserved[1] = {Real(4)}; + const Real initial[1] = {Real(1)}; + const auto problem = RejectingProblemFactory{}(conserved); + const auto methods = + pops::recovery_methods(pops::prepared_local_nonlinear_recovery<1>(RejectingProblemFactory{})); + const auto plan = pops::prepare_variable_recovery<1>(PositiveCandidate{}, methods); + + Real accepted[1] = {Real(9)}; + pops::RecoveryWarmStartSlot<1> cache; + const Real cached[1] = {Real(8)}; + cache.store(cached, 3, 7); + pops::RecoveryPublicationTransaction<1> transaction(accepted, cache); + + const std::uint64_t before = g_heap_allocations.load(std::memory_order_relaxed); + const auto local = pops::solve_prepared_local_nonlinear(problem, initial); + const auto recovered = pops::recover_prepared_variable(plan, conserved, initial); + const bool published = transaction.publish_tentative(recovered, 4, 8); + const bool rolled_back = transaction.rollback(); + const std::uint64_t after = g_heap_allocations.load(std::memory_order_relaxed); + + EXPECT_EQ(local.status, pops::LocalNonlinearStatus::kEvaluationReject); + EXPECT_EQ(local.reason_code, std::uint32_t{731}); + EXPECT_EQ(local.value[0], initial[0]); + EXPECT_EQ(recovered.status, pops::RecoveryStatus::kRejected); + EXPECT_EQ(recovered.cause, pops::RecoveryCause::kEvaluationReject); + EXPECT_EQ(recovered.reason_code, std::uint32_t{731}); + EXPECT_FALSE(published); + EXPECT_TRUE(rolled_back); + EXPECT_EQ(after, before); + EXPECT_EQ(accepted[0], Real(9)); + EXPECT_EQ(cache.value[0], Real(8)); + EXPECT_EQ(cache.topology_generation, std::uint64_t{3}); + EXPECT_EQ(cache.state_generation, std::uint64_t{7}); +} diff --git a/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp b/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp new file mode 100644 index 000000000..dbeb96328 --- /dev/null +++ b/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp @@ -0,0 +1,344 @@ +#include + +#include +#include +#include +#include + +#include +#include + +namespace { + +using pops::Real; + +template +struct AcceptPositive { + POPS_HD bool operator()(const Real (&value)[N], int* component = nullptr) const { + for (int index = 0; index < N; ++index) + if (!(value[index] > Real(0))) { + if (component != nullptr) + *component = index; + return false; + } + if (component != nullptr) + *component = -1; + return true; + } +}; + +struct UnavailableClosedForm { + static constexpr pops::RecoveryMethodKind kind = pops::RecoveryMethodKind::kClosedForm; + + POPS_HD pops::RecoveryMethodResult<1> operator()(const Real (&)[1], const Real (&)[1]) const { + return pops::RecoveryMethodResult<1>::continue_chain( + pops::RecoveryCause::kClosedFormUnavailable); + } +}; + +struct SquareResidual { + Real target = 0; + + POPS_HD pops::LocalNonlinearEvaluationResult operator()(const Real (&value)[1], + Real (&residual)[1]) const { + residual[0] = value[0] * value[0] - target; + return pops::LocalNonlinearEvaluationResult::ok(); + } +}; + +struct SquareProblemFactory { + POPS_HD auto operator()(const Real (&conserved)[1]) const { + pops::PreparedLocalNonlinearControls controls; + controls.max_iterations = 16; + controls.absolute_tolerance = Real(1e-13); + return pops::prepare_local_nonlinear_problem<1>(SquareResidual{conserved[0]}, + pops::FiniteDifferenceLocalJacobian<1>{}, + AcceptPositive<1>{}, controls); + } +}; + +struct NegativeCandidate { + static constexpr pops::RecoveryMethodKind kind = pops::RecoveryMethodKind::kClosedForm; + + POPS_HD pops::RecoveryMethodResult<1> operator()(const Real (&)[1], const Real (&)[1]) const { + const Real value[1] = {Real(-2)}; + return pops::RecoveryMethodResult<1>::candidate(value); + } +}; + +struct ExplicitReject { + static constexpr pops::RecoveryMethodKind kind = pops::RecoveryMethodKind::kCustom; + + POPS_HD pops::RecoveryMethodResult<1> operator()(const Real (&)[1], const Real (&)[1]) const { + return pops::RecoveryMethodResult<1>::reject(pops::RecoveryCause::kExplicitRejection); + } +}; + +struct NonFiniteCandidate { + static constexpr pops::RecoveryMethodKind kind = pops::RecoveryMethodKind::kCustom; + + POPS_HD pops::RecoveryMethodResult<1> operator()(const Real (&)[1], const Real (&)[1]) const { + const Real value[1] = {std::numeric_limits::quiet_NaN()}; + return pops::RecoveryMethodResult<1>::candidate(value); + } +}; + +struct RepairCandidate { + static constexpr pops::RecoveryMethodKind kind = pops::RecoveryMethodKind::kRepair; + + POPS_HD pops::RecoveryMethodResult<1> operator()(const Real (&)[1], const Real (&)[1]) const { + const Real value[1] = {Real(1)}; + return pops::RecoveryMethodResult<1>::candidate(value); + } +}; + +struct GuardedScalarHyperbolic { + using State = pops::StateVec<1>; + using Prim = pops::StateVec<1>; + using Aux = pops::Aux; + static constexpr int n_vars = 1; + + POPS_HD State flux(const State& value, const Aux&, int) const { return value; } + POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(1); } + POPS_HD Prim to_primitive(const State& value) const { return value; } + POPS_HD State to_conservative(const Prim& value) const { return value; } + POPS_HD bool recovery_admissible(const Prim& value, int* failing_component) const { + if (!(value[0] > Real(0))) { + if (failing_component != nullptr) + *failing_component = 0; + return false; + } + if (failing_component != nullptr) + *failing_component = -1; + return true; + } + static pops::VariableSet conservative_vars() { + return {pops::VariableKind::Conservative, {"q"}, 1, {pops::VariableRole::Scalar}}; + } + static pops::VariableSet primitive_vars() { + return {pops::VariableKind::Primitive, {"q"}, 1, {pops::VariableRole::Scalar}}; + } +}; + +using GuardedScalarModel = + pops::CompositeModel; + +static_assert(pops::HyperbolicPhysicalModel); +static_assert(pops::HasRecoveryAdmissibility); + +TEST(PreparedVariableRecovery, ordered_chain_uses_common_prepared_solver) { + const auto methods = pops::recovery_methods( + UnavailableClosedForm{}, pops::prepared_local_nonlinear_recovery<1>(SquareProblemFactory{})); + const auto plan = pops::prepare_variable_recovery<1>(AcceptPositive<1>{}, methods); + + static_assert(decltype(methods)::size == 2); + EXPECT_EQ(plan.method_kind(0), pops::RecoveryMethodKind::kClosedForm); + EXPECT_EQ(plan.method_kind(1), pops::RecoveryMethodKind::kPreparedLocalNonlinear); + EXPECT_EQ(plan.method_kind(2), pops::RecoveryMethodKind::kUnknown); + + const Real conserved[1] = {Real(4)}; + const Real initial_guess[1] = {Real(1)}; + const auto outcome = pops::recover_prepared_variable(plan, conserved, initial_guess); + + ASSERT_TRUE(outcome.recovered()); + EXPECT_TRUE(outcome.publication_permitted()); + EXPECT_EQ(outcome.attempted_methods, 2); + EXPECT_EQ(outcome.selected_method, 1); + EXPECT_EQ(outcome.last_method, 1); + EXPECT_EQ(outcome.selected_method_kind, pops::RecoveryMethodKind::kPreparedLocalNonlinear); + EXPECT_EQ(outcome.last_method_kind, pops::RecoveryMethodKind::kPreparedLocalNonlinear); + EXPECT_GT(outcome.total_iterations, 0); + EXPECT_NEAR(outcome.value[0], Real(2), Real(1e-10)); +} + +TEST(PreparedVariableRecovery, type_erased_report_preserves_selected_method_kind) { + const auto methods = pops::recovery_methods( + UnavailableClosedForm{}, pops::prepared_local_nonlinear_recovery<1>(SquareProblemFactory{})); + const auto plan = pops::prepare_variable_recovery<1>(AcceptPositive<1>{}, methods); + const Real conserved[1] = {Real(4)}; + const Real initial_guess[1] = {Real(1)}; + + const auto report = + pops::recovery_report(pops::recover_prepared_variable(plan, conserved, initial_guess)); + + ASSERT_TRUE(report.publication_permitted()); + EXPECT_EQ(report.selected_method, 1); + EXPECT_EQ(report.last_method, 1); + EXPECT_EQ(report.selected_method_kind, pops::RecoveryMethodKind::kPreparedLocalNonlinear); + EXPECT_EQ(report.last_method_kind, pops::RecoveryMethodKind::kPreparedLocalNonlinear); + EXPECT_STREQ(pops::recovery_method_kind_name(report.selected_method_kind), + "prepared_local_nonlinear"); +} + +TEST(PreparedVariableRecovery, rejected_chain_never_changes_solution_or_cache) { + const auto plan = pops::prepare_variable_recovery<1>( + AcceptPositive<1>{}, pops::recovery_methods(NegativeCandidate{}, ExplicitReject{})); + const Real conserved[1] = {Real(4)}; + const Real initial_guess[1] = {Real(1)}; + const auto outcome = pops::recover_prepared_variable(plan, conserved, initial_guess); + + EXPECT_EQ(outcome.status, pops::RecoveryStatus::kRejected); + EXPECT_EQ(outcome.cause, pops::RecoveryCause::kExplicitRejection); + EXPECT_EQ(outcome.attempted_methods, 2); + EXPECT_EQ(outcome.selected_method, -1); + EXPECT_EQ(outcome.selected_method_kind, pops::RecoveryMethodKind::kUnknown); + EXPECT_EQ(outcome.last_method, 1); + EXPECT_EQ(outcome.last_method_kind, pops::RecoveryMethodKind::kCustom); + EXPECT_FALSE(outcome.publication_permitted()); + + Real accepted[1] = {Real(9)}; + pops::RecoveryWarmStartSlot<1> cache; + const Real cached[1] = {Real(8)}; + cache.store(cached, 3, 7); + pops::RecoveryPublicationTransaction<1> transaction(accepted, cache); + + EXPECT_FALSE(transaction.publish_tentative(outcome, 4, 8)); + EXPECT_EQ(accepted[0], Real(9)); + EXPECT_EQ(cache.value[0], Real(8)); + EXPECT_EQ(cache.topology_generation, std::uint64_t{3}); + EXPECT_EQ(cache.state_generation, std::uint64_t{7}); + EXPECT_TRUE(transaction.rollback()); + EXPECT_EQ(accepted[0], Real(9)); + EXPECT_EQ(cache.value[0], Real(8)); +} + +TEST(PreparedVariableRecovery, rejected_report_names_last_method_without_forging_selection) { + const auto plan = pops::prepare_variable_recovery<1>( + AcceptPositive<1>{}, pops::recovery_methods(NegativeCandidate{}, ExplicitReject{})); + const Real conserved[1] = {Real(4)}; + const Real initial_guess[1] = {Real(1)}; + + const auto report = + pops::recovery_report(pops::recover_prepared_variable(plan, conserved, initial_guess)); + + EXPECT_EQ(report.status, pops::RecoveryStatus::kRejected); + EXPECT_EQ(report.selected_method, -1); + EXPECT_EQ(report.selected_method_kind, pops::RecoveryMethodKind::kUnknown); + EXPECT_EQ(report.last_method, 1); + EXPECT_EQ(report.last_method_kind, pops::RecoveryMethodKind::kCustom); + EXPECT_STREQ(pops::recovery_method_kind_name(report.last_method_kind), "custom"); +} + +TEST(PreparedVariableRecovery, tentative_publication_rolls_back_solution_and_warm_start) { + const auto plan = pops::prepare_variable_recovery<1>( + AcceptPositive<1>{}, + pops::recovery_methods(pops::prepared_local_nonlinear_recovery<1>(SquareProblemFactory{}))); + const Real conserved[1] = {Real(4)}; + const Real initial_guess[1] = {Real(1)}; + const auto outcome = pops::recover_prepared_variable(plan, conserved, initial_guess); + ASSERT_TRUE(outcome.recovered()); + + Real accepted[1] = {Real(9)}; + pops::RecoveryWarmStartSlot<1> cache; + const Real cached[1] = {Real(8)}; + cache.store(cached, 3, 7); + { + pops::RecoveryPublicationTransaction<1> transaction(accepted, cache); + ASSERT_TRUE(transaction.publish_tentative(outcome, 4, 8)); + EXPECT_NEAR(accepted[0], Real(2), Real(1e-10)); + EXPECT_NEAR(cache.value[0], Real(2), Real(1e-10)); + EXPECT_EQ(cache.topology_generation, std::uint64_t{4}); + EXPECT_EQ(cache.state_generation, std::uint64_t{8}); + ASSERT_TRUE(transaction.rollback()); + } + EXPECT_EQ(accepted[0], Real(9)); + EXPECT_EQ(cache.value[0], Real(8)); + EXPECT_EQ(cache.topology_generation, std::uint64_t{3}); + EXPECT_EQ(cache.state_generation, std::uint64_t{7}); + + pops::RecoveryPublicationTransaction<1> committed(accepted, cache); + ASSERT_TRUE(committed.publish_tentative(outcome, 4, 8)); + ASSERT_TRUE(committed.commit()); + EXPECT_FALSE(committed.rollback()); + EXPECT_NEAR(accepted[0], Real(2), Real(1e-10)); + EXPECT_NEAR(cache.value[0], Real(2), Real(1e-10)); +} + +TEST(PreparedVariableRecovery, scope_exit_rolls_back_an_uncommitted_publication) { + const auto plan = pops::prepare_variable_recovery<1>( + AcceptPositive<1>{}, + pops::recovery_methods(pops::prepared_local_nonlinear_recovery<1>(SquareProblemFactory{}))); + const Real conserved[1] = {Real(4)}; + const Real initial_guess[1] = {Real(1)}; + const auto outcome = pops::recover_prepared_variable(plan, conserved, initial_guess); + ASSERT_TRUE(outcome.recovered()); + + Real accepted[1] = {Real(9)}; + pops::RecoveryWarmStartSlot<1> cache; + const Real cached[1] = {Real(8)}; + cache.store(cached, 3, 7); + { + pops::RecoveryPublicationTransaction<1> transaction(accepted, cache); + ASSERT_TRUE(transaction.publish_tentative(outcome, 4, 8)); + EXPECT_NEAR(accepted[0], Real(2), Real(1e-10)); + } + EXPECT_EQ(accepted[0], Real(9)); + EXPECT_EQ(cache.value[0], Real(8)); + EXPECT_EQ(cache.topology_generation, std::uint64_t{3}); + EXPECT_EQ(cache.state_generation, std::uint64_t{7}); +} + +TEST(PreparedVariableRecovery, stale_warm_start_is_an_explicit_non_mutating_miss) { + pops::RecoveryWarmStartSlot<2> cache; + const Real cached[2] = {Real(3), Real(4)}; + cache.store(cached, 5, 9); + Real destination[2] = {Real(11), Real(12)}; + + EXPECT_FALSE(cache.load_if_current(6, 9, destination)); + EXPECT_EQ(destination[0], Real(11)); + EXPECT_EQ(destination[1], Real(12)); + EXPECT_EQ(cache.value[0], Real(3)); + EXPECT_EQ(cache.value[1], Real(4)); + EXPECT_TRUE(cache.valid); + + EXPECT_TRUE(cache.load_if_current(5, 9, destination)); + EXPECT_EQ(destination[0], Real(3)); + EXPECT_EQ(destination[1], Real(4)); +} + +TEST(PreparedVariableRecovery, malformed_and_repair_candidates_fail_closed) { + const Real conserved[1] = {Real(4)}; + const Real initial_guess[1] = {Real(1)}; + + const auto malformed_plan = pops::prepare_variable_recovery<1>( + AcceptPositive<1>{}, pops::recovery_methods(NonFiniteCandidate{}, ExplicitReject{})); + const auto malformed = pops::recover_prepared_variable(malformed_plan, conserved, initial_guess); + EXPECT_EQ(malformed.status, pops::RecoveryStatus::kInvalidContract); + EXPECT_EQ(malformed.cause, pops::RecoveryCause::kNonFiniteCandidate); + EXPECT_EQ(malformed.attempted_methods, 1); + EXPECT_FALSE(malformed.publication_permitted()); + + const auto repair_plan = pops::prepare_variable_recovery<1>( + AcceptPositive<1>{}, pops::recovery_methods(RepairCandidate{})); + const auto repair = pops::recover_prepared_variable(repair_plan, conserved, initial_guess); + EXPECT_EQ(repair.status, pops::RecoveryStatus::kInvalidContract); + EXPECT_EQ(repair.cause, pops::RecoveryCause::kRepairPublicationForbidden); + EXPECT_FALSE(repair.publication_permitted()); +} + +TEST(PreparedVariableRecovery, model_declared_admissibility_blocks_publication) { + const GuardedScalarModel model{}; + const auto plan = pops::prepare_model_variable_recovery(model); + EXPECT_EQ(plan.method_kind(0), pops::RecoveryMethodKind::kClosedForm); + + const Real negative[1] = {Real(-1)}; + const Real negative_guess[1] = {Real(2)}; + const auto rejected = pops::recover_prepared_variable(plan, negative, negative_guess); + EXPECT_EQ(rejected.status, pops::RecoveryStatus::kExhausted); + EXPECT_EQ(rejected.cause, pops::RecoveryCause::kInadmissibleCandidate); + EXPECT_EQ(rejected.failing_component, 0); + EXPECT_FALSE(rejected.publication_permitted()); +} + +TEST(PreparedVariableRecovery, model_declared_admissibility_permits_valid_candidate) { + const GuardedScalarModel model{}; + const auto plan = pops::prepare_model_variable_recovery(model); + const Real positive[1] = {Real(3)}; + const Real positive_guess[1] = {Real(1)}; + const auto recovered = pops::recover_prepared_variable(plan, positive, positive_guess); + ASSERT_TRUE(recovered.publication_permitted()); + EXPECT_EQ(recovered.failing_component, -1); + EXPECT_EQ(recovered.value[0], Real(3)); +} + +} // namespace diff --git a/tests/cpp/unit/numerics/test_weno_convergence.cpp b/tests/cpp/unit/numerics/test_weno_convergence.cpp index d10b0d853..cb4a7d899 100644 --- a/tests/cpp/unit/numerics/test_weno_convergence.cpp +++ b/tests/cpp/unit/numerics/test_weno_convergence.cpp @@ -8,8 +8,11 @@ #include #include +#include #include #include +#include +#include using namespace pops; @@ -53,6 +56,78 @@ struct AmbiguousPolicy { struct MissingPolicy {}; +/// Minimal conservative model used only to exercise the production face-state reconstruction +/// protocol. The qualification below never calls a limiter formula directly. +struct ScalarReconstructionModel { + using State = StateVec<1>; + static constexpr int n_vars = 1; +}; + +struct PeriodicFaceStates { + std::vector left; + std::vector right; + bool publication_permitted = true; +}; + +double favg(double a, double b); + +int periodic_index(int index, int size) { + const int remainder = index % size; + return remainder < 0 ? remainder + size : remainder; +} + +template +PeriodicFaceStates reconstruct_periodic_faces(const std::vector& cell_averages) { + const int size = static_cast(cell_averages.size()); + Fab2D values(Box2D::from_extents(size, 1), ScalarReconstructionModel::n_vars, Limiter::n_ghost); + for (int i = values.grown_box().lo[0]; i <= values.grown_box().hi[0]; ++i) + values(i, 0, 0) = cell_averages[periodic_index(i, size)]; + + const ScalarReconstructionModel model{}; + const Limiter limiter{}; + PeriodicFaceStates result{std::vector(size), std::vector(size), true}; + for (int i = 0; i < size; ++i) { + const auto left = + reconstruct_recovered(model, values.const_array(), i, 0, 0, Real(-1), limiter, false); + const auto right = + reconstruct_recovered(model, values.const_array(), i, 0, 0, Real(1), limiter, false); + result.publication_permitted = result.publication_permitted && left.publication_permitted() && + right.publication_permitted(); + result.left[i] = left.value[0]; + result.right[i] = right.value[0]; + } + return result; +} + +struct SmoothFaceError { + double l1 = 0; + bool publication_permitted = false; +}; + +template +SmoothFaceError smooth_periodic_face_error(int size) { + const double dx = 1.0 / static_cast(size); + std::vector cell_averages(size); + for (int i = 0; i < size; ++i) + cell_averages[i] = Real(favg(i * dx, (i + 1) * dx)); + + const auto reconstructed = reconstruct_periodic_faces(cell_averages); + double error = 0; + for (int i = 0; i < size; ++i) { + const double exact = std::sin(Real(2) * kPi * (i + 1) * dx); + error += std::fabs(static_cast(reconstructed.right[i]) - exact); + } + return {error / static_cast(size), reconstructed.publication_permitted}; +} + +double interface_jump_budget(const PeriodicFaceStates& states) { + double result = 0; + const int size = static_cast(states.left.size()); + for (int i = 0; i < size; ++i) + result += std::fabs(static_cast(states.left[(i + 1) % size] - states.right[i])); + return result; +} + /// A nonlinear conservative/primitive conversion makes the two reconstruction paths observably /// different while remaining exactly invertible for the positive test data. struct PrimitiveTestModel { @@ -79,6 +154,10 @@ struct PrimitiveTestModel { }; static_assert(SlopeReconstruction); +static_assert(ReconstructionPolicy); +static_assert(ReconstructionPolicy); +static_assert(MC::formal_order == 2 && MC::n_ghost == 2); +static_assert(Superbee::formal_order == 2 && Superbee::n_ghost == 2); static_assert(!StencilReconstruction); static_assert(StencilReconstruction); static_assert(ReconstructionPolicy); @@ -104,6 +183,136 @@ TEST(test_weno_convergence, reconstruction_protocol_is_independent_of_storage_ra EXPECT_EQ(policy.limited_slope(Real(2), Real(4)), Real(3)); } +TEST(test_muscl_limiters, mc_and_superbee_match_reference_formulas) { + const MC mc{}; + const Superbee superbee{}; + + EXPECT_EQ(mc.limited_slope(Real(1), Real(3)), Real(2)); + EXPECT_EQ(mc.limited_slope(Real(2), Real(4)), Real(3)); + EXPECT_EQ(mc.limited_slope(Real(3), Real(1)), Real(2)); + EXPECT_EQ(superbee.limited_slope(Real(1), Real(3)), Real(2)); + EXPECT_EQ(superbee.limited_slope(Real(2), Real(4)), Real(4)); + EXPECT_EQ(superbee.limited_slope(Real(3), Real(1)), Real(2)); +} + +TEST(test_muscl_limiters, zero_opposite_sign_symmetry_and_homogeneity) { + const MC mc{}; + const Superbee superbee{}; + for (const auto limiter : {0, 1}) { + const auto slope = [&](Real a, Real b) { + return limiter == 0 ? mc.limited_slope(a, b) : superbee.limited_slope(a, b); + }; + EXPECT_EQ(slope(Real(0), Real(4)), Real(0)); + EXPECT_EQ(slope(Real(4), Real(0)), Real(0)); + EXPECT_EQ(slope(Real(-2), Real(3)), Real(0)); + EXPECT_EQ(slope(Real(2), Real(-3)), Real(0)); + EXPECT_EQ(slope(Real(-2), Real(-4)), -slope(Real(2), Real(4))); + EXPECT_EQ(slope(Real(6), Real(12)), Real(3) * slope(Real(2), Real(4))); + } +} + +TEST(test_muscl_limiters, sweby_tvd_bounds_and_finite_extremes) { + const MC mc{}; + const Superbee superbee{}; + for (const Real backward : {Real(0.25), Real(1), Real(2), Real(8)}) { + for (const Real forward : {Real(0.5), Real(1), Real(4), Real(16)}) { + const Real tvd_bound = Real(2) * std::min(backward, forward); + for (const Real slope : + {mc.limited_slope(backward, forward), superbee.limited_slope(backward, forward)}) { + EXPECT_GE(slope, Real(0)); + EXPECT_LE(slope, tvd_bound); + } + } + } + + const Real maximum = std::numeric_limits::max(); + EXPECT_TRUE(std::isfinite(mc.limited_slope(maximum, maximum))); + EXPECT_TRUE(std::isfinite(superbee.limited_slope(maximum, maximum))); + EXPECT_EQ(mc.limited_slope(maximum, maximum), maximum); + EXPECT_EQ(superbee.limited_slope(maximum, maximum), maximum); + EXPECT_EQ(mc.limited_slope(-maximum, -maximum), -maximum); + EXPECT_EQ(superbee.limited_slope(-maximum, -maximum), -maximum); +} + +TEST(test_muscl_limiter_qualification, + mc_and_superbee_are_second_order_on_smooth_periodic_cell_averages) { + const auto mc_128 = smooth_periodic_face_error(128); + const auto mc_256 = smooth_periodic_face_error(256); + const auto superbee_128 = smooth_periodic_face_error(128); + const auto superbee_256 = smooth_periodic_face_error(256); + const auto minmod_256 = smooth_periodic_face_error(256); + const auto vanleer_256 = smooth_periodic_face_error(256); + + EXPECT_TRUE(mc_128.publication_permitted && mc_256.publication_permitted); + EXPECT_TRUE(superbee_128.publication_permitted && superbee_256.publication_permitted); + EXPECT_TRUE(minmod_256.publication_permitted && vanleer_256.publication_permitted); + + const double mc_order = std::log(mc_128.l1 / mc_256.l1) / std::log(2.0); + const double superbee_order = std::log(superbee_128.l1 / superbee_256.l1) / std::log(2.0); + EXPECT_GT(mc_order, 1.85); + EXPECT_LT(mc_order, 2.20); + EXPECT_GT(superbee_order, 1.85); + EXPECT_LT(superbee_order, 2.20); + + // Fixed-resolution characterization, not a universal ranking: MC tracks the smoother Van Leer + // reconstruction on this wave, while Superbee remains more accurate than Minmod but less + // accurate than Van Leer around smooth extrema. + EXPECT_LT(mc_256.l1, minmod_256.l1); + EXPECT_LT(superbee_256.l1, minmod_256.l1); + EXPECT_LT(vanleer_256.l1, superbee_256.l1); +} + +TEST(test_muscl_limiter_qualification, + discontinuities_create_no_extremum_and_expose_interface_dissipation_budget) { + const auto assert_locally_bounded = [](const std::vector& averages, + const PeriodicFaceStates& states) { + ASSERT_TRUE(states.publication_permitted); + const int size = static_cast(averages.size()); + const Real tolerance = Real(32) * std::numeric_limits::epsilon(); + for (int i = 0; i < size; ++i) { + const Real left_min = std::min(averages[periodic_index(i - 1, size)], averages[i]); + const Real left_max = std::max(averages[periodic_index(i - 1, size)], averages[i]); + const Real right_min = std::min(averages[i], averages[(i + 1) % size]); + const Real right_max = std::max(averages[i], averages[(i + 1) % size]); + EXPECT_GE(states.left[i], left_min - tolerance); + EXPECT_LE(states.left[i], left_max + tolerance); + EXPECT_GE(states.right[i], right_min - tolerance); + EXPECT_LE(states.right[i], right_max + tolerance); + } + }; + + const std::vector discontinuity = {Real(0), Real(0), Real(0), Real(0), + Real(1), Real(1), Real(1), Real(1)}; + assert_locally_bounded(discontinuity, reconstruct_periodic_faces(discontinuity)); + assert_locally_bounded(discontinuity, reconstruct_periodic_faces(discontinuity)); + + // Binary fractions keep this steep periodic shoulder deterministic in float and double. For a + // scalar Rusanov flux at fixed wave speed, sum |U_R-U_L| is proportional to the absolute + // dissipative interface penalty. The ordering is deliberately fixture-specific and records the + // actual trade-off instead of claiming that one limiter is universally least dissipative. + const std::vector shoulder = { + Real(0), Real(0), Real(1) / 16, Real(3) / 16, Real(6) / 16, Real(10) / 16, + Real(13) / 16, Real(15) / 16, Real(1), Real(1), Real(15) / 16, Real(13) / 16, + Real(10) / 16, Real(6) / 16, Real(3) / 16, Real(1) / 16, + }; + const auto minmod = reconstruct_periodic_faces(shoulder); + const auto vanleer = reconstruct_periodic_faces(shoulder); + const auto mc = reconstruct_periodic_faces(shoulder); + const auto superbee = reconstruct_periodic_faces(shoulder); + assert_locally_bounded(shoulder, minmod); + assert_locally_bounded(shoulder, vanleer); + assert_locally_bounded(shoulder, mc); + assert_locally_bounded(shoulder, superbee); + + const double minmod_jump = interface_jump_budget(minmod); + const double vanleer_jump = interface_jump_budget(vanleer); + const double mc_jump = interface_jump_budget(mc); + const double superbee_jump = interface_jump_budget(superbee); + EXPECT_LT(mc_jump, vanleer_jump); + EXPECT_LT(vanleer_jump, superbee_jump); + EXPECT_LT(superbee_jump, minmod_jump); +} + TEST(test_weno_convergence, external_sampled_policy_controls_offsets_and_orientation) { const Box2D valid = Box2D::from_extents(11, 1); Fab2D values(valid, PrimitiveTestModel::n_vars, ExternalFourSamplePolicy::n_ghost); diff --git a/tests/cpp/unit/runtime/test_component_interfaces.cpp b/tests/cpp/unit/runtime/test_component_interfaces.cpp index 155514c4f..8888958d1 100644 --- a/tests/cpp/unit/runtime/test_component_interfaces.cpp +++ b/tests/cpp/unit/runtime/test_component_interfaces.cpp @@ -580,6 +580,66 @@ TEST(ComponentInterfaces, ExactAbiConsumersExecuteEveryClosedScientificFamily) { EXPECT_THROW(pops::component::apply_ghost_boundary(ghost_api, nullptr, invalid_region, status), std::invalid_argument); + std::array transformed_outward_flux{}; + const std::array lower_outward_normal{-1.0, 0.0}; + const std::array face_measure{0.5}; + PopsComponentActionV1 boundary_flux_action = POPS_COMPONENT_ABORT_RUN_V1; + PopsBoundaryFluxApiV1 boundary_flux_api{ + abi_header(sizeof(PopsBoundaryFluxApiV1), POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1), + +[](void*, const PopsBoundaryFluxRequestV1* request, PopsBoundaryFluxResultV1* result) { + if (request->region.kind != POPS_BOUNDARY_FACE_V1 || + request->outward_normals.component_count != 2 || + values(request->outward_normals)[0] != -1.0 || request->face_measures[0] != 0.5) + return 12; + const auto* base = values(request->base_outward_normal_flux); + auto* output = values(result->outward_normal_flux); + for (std::size_t component = 0; + component < request->base_outward_normal_flux.component_count; ++component) + output[component] = base[component] + 3.0; + result->actions[0] = POPS_COMPONENT_CONTINUE_V1; + result->status = ok_status(); + return 0; + }}; + auto base_outward_flux_view = abi::const_field_view(left.data(), 1, 1, 2); + base_outward_flux_view.centering = POPS_FIELD_CENTERING_FACE_V1; + base_outward_flux_view.centering_axes = 1u; + auto transformed_outward_flux_view = abi::field_view(transformed_outward_flux.data(), 1, 1, 2); + transformed_outward_flux_view.centering = POPS_FIELD_CENTERING_FACE_V1; + transformed_outward_flux_view.centering_axes = 1u; + PopsBoundaryFluxRequestV1 boundary_flux_request{ + sizeof(PopsBoundaryFluxRequestV1), + "case::boundary-flux-provider", + "case::state", + base_outward_flux_view, + abi::const_field_view(normal.data(), 1, 1, 2), + abi::const_field_view(lower_outward_normal.data(), 1, 1, 2), + face_measure.data(), + face_region, + 0, + nullptr, + 0, + nullptr, + abi::logical_time(), + execution}; + PopsBoundaryFluxResultV1 boundary_flux_result{ + sizeof(PopsBoundaryFluxResultV1), transformed_outward_flux_view, &boundary_flux_action, {}}; + EXPECT_EQ(pops::component::transform_boundary_flux(boundary_flux_api, nullptr, + boundary_flux_request, boundary_flux_result), + 0); + EXPECT_EQ(transformed_outward_flux, (std::array{5.0, 7.0})); + EXPECT_EQ(boundary_flux_action, POPS_COMPONENT_CONTINUE_V1); + const std::array wrong_lower_normal{1.0, 0.0}; + auto wrong_orientation = boundary_flux_request; + wrong_orientation.outward_normals = abi::const_field_view(wrong_lower_normal.data(), 1, 1, 2); + EXPECT_THROW(pops::component::transform_boundary_flux(boundary_flux_api, nullptr, + wrong_orientation, boundary_flux_result), + std::invalid_argument); + auto mismatched_flux_output = boundary_flux_result; + mismatched_flux_output.outward_normal_flux.component_count = 1; + EXPECT_THROW(pops::component::transform_boundary_flux( + boundary_flux_api, nullptr, boundary_flux_request, mismatched_flux_output), + std::invalid_argument); + std::array direction{1.0, 2.0}, boundary_output{}; const auto field_eval = +[](void*, const PopsFieldBoundaryRequestV1* request, PopsComponentStatusV1* result) { diff --git a/tests/cpp/unit/runtime/test_program_context_contract.cpp b/tests/cpp/unit/runtime/test_program_context_contract.cpp index f94a8a44e..85ff832eb 100644 --- a/tests/cpp/unit/runtime/test_program_context_contract.cpp +++ b/tests/cpp/unit/runtime/test_program_context_contract.cpp @@ -24,8 +24,10 @@ #include +#include #include #include +#include #include // NoSource #include // CompositeModel #include // Euler @@ -288,9 +290,14 @@ TEST(ProgramContextContract, GroupedBoundaryRegistryUsesEveryProvisionalStageSta sim.install_block_state_route("b", b_state); const std::vector faces(4, "periodic"); const std::vector values(4, 0.0); - sim.install_boundary_plan("a", "case::block::a::boundary", 1, faces, values, 1, {}, a_state, - PreparedBoundaryReadDependencies{{b_state}, {}}); - sim.install_boundary_plan("b", "case::block::b::boundary", 1, faces, values, 1, {}, b_state); + const std::vector a_faces = {"case::block::a::xlo", "case::block::a::xhi", + "case::block::a::ylo", "case::block::a::yhi"}; + const std::vector b_faces = {"case::block::b::xlo", "case::block::b::xhi", + "case::block::b::ylo", "case::block::b::yhi"}; + sim.install_boundary_plan("a", "case::block::a::boundary", 1, faces, values, a_faces, {"Scalar"}, + {}, a_state, PreparedBoundaryReadDependencies{{b_state}, {}}); + sim.install_boundary_plan("b", "case::block::b::boundary", 1, faces, values, b_faces, {"Scalar"}, + {}, b_state); const auto a_plan = sim.grid_context("a").boundary_plan; ASSERT_NE(a_plan, nullptr); const auto b_read = a_plan->prepare_state_read(b_state); @@ -351,6 +358,150 @@ TEST(ProgramContextContract, GroupedBoundaryRegistryUsesEveryProvisionalStageSta << "an atomic group identity must never alias one of its member rate nodes"; } +TEST(ProgramContextContract, SystemPreparedSlipWallFillsDeepPhysicalGhosts) { + ensure_kokkos(); + SystemConfig cfg; + cfg.n = 4; + cfg.L = 1.0; + cfg.periodicity = {false, false}; + System sim(cfg); + const std::string state_identity = "case::block::fluid::state::U"; + sim.install_block_state_route("fluid", state_identity); + sim.install_boundary_plan( + "fluid", "case::block::fluid::boundary", 2, + {"slip_wall", "slip_wall", "slip_wall", "slip_wall"}, std::vector(20, 0.0), + {"case::block::fluid::xlo", "case::block::fluid::xhi", "case::block::fluid::ylo", + "case::block::fluid::yhi"}, + {"Density", "MomentumX", "MomentumX", "MomentumY", "AxialZ"}, {}, state_identity); + sim.install_block("fluid", 5, VariableSet{}, VariableSet{}, 1.0, BlockClosures{}, {}, {}, 1, true, + 1); + + MultiFab& state = sim.block_state(0); + ASSERT_GE(state.n_grow(), 2); + state.set_val(Real(-99)); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { + values(i, j, 0) = Real(1); + values(i, j, 1) = Real(2); + values(i, j, 2) = Real(5); + values(i, j, 3) = Real(3); + values(i, j, 4) = Real(4); + }); + } + device_fence(); + const auto lane = ExecutionLane::world("test.system.deep-slip-wall"); + const runtime::multiblock::BoundaryEvaluationPoint point{"clock.system-slip", 0, 0, 0, 0, + amr::Rational(0, 1), 0.1, 0.0}; + PreparedGridBoundarySession boundary(sim.grid_context("fluid"), lane, state, point); + boundary.fill(state, point); + device_fence(); + + if (state.local_size() > 0) { + const ConstArray4 values = state.fab(0).const_array(); + EXPECT_EQ(values(-2, 2, 1), Real(-2)); + EXPECT_EQ(values(-2, 2, 2), Real(-5)); + EXPECT_EQ(values(-2, 2, 4), Real(-4)); + EXPECT_EQ(values(2, -2, 3), Real(-3)); + EXPECT_EQ(values(2, -2, 4), Real(-4)); + } +} + +TEST(ProgramContextContract, SystemPreparesPrimitiveFixedStateWithExactCompiledModelConversion) { + ensure_kokkos(); + SystemConfig cfg; + cfg.n = 4; + cfg.L = 1.0; + cfg.periodicity = {false, false}; + System sim(cfg); + const std::string state_identity = "case::block::fluid::state::U"; + sim.install_block_state_route("fluid", state_identity); + std::vector face_values; + for (const double primitive : {2.0, 3.0, -1.0, 4.0}) + face_values.insert(face_values.end(), {0.0, primitive, 0.0, 0.0}); + sim.install_boundary_plan("fluid", "case::block::fluid::boundary", 2, + {"foextrap", "dirichlet", "foextrap", "foextrap"}, face_values, + {"case::block::fluid::xlo", "case::block::fluid::xhi", + "case::block::fluid::ylo", "case::block::fluid::yhi"}, + {"Density", "MomentumX", "MomentumY", "Energy"}, {}, state_identity, {}, + {}, {"conservative", "primitive", "conservative", "conservative"}, + {"", "case::block::fluid::model-p2c", "", ""}); + ASSERT_TRUE(sim.grid_context("fluid").boundary_plan->requires_fixed_state_conversion()); + + add_compiled_model(sim, "fluid", GasModel{Euler{kGamma}, NoSource{}, NoEll{}}, "minmod", + "rusanov", "conservative", "explicit", kGamma); + ASSERT_FALSE(sim.grid_context("fluid").boundary_plan->requires_fixed_state_conversion()); + + MultiFab& state = sim.block_state(0); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { + for (int component = 0; component < kNcomp; ++component) + values(i, j, component) = Real(1); + }); + } + device_fence(); + const auto lane = ExecutionLane::world("test.system.primitive-fixed-state"); + const runtime::multiblock::BoundaryEvaluationPoint point{ + "clock.system-primitive-inflow", 0, 0, 0, 0, amr::Rational(0, 1), 0.1, 0.0}; + PreparedGridBoundarySession boundary(sim.grid_context("fluid"), lane, state, point); + boundary.fill(state, point); + device_fence(); + + if (state.local_size() > 0) { + const ConstArray4 values = state.fab(0).const_array(); + EXPECT_EQ(values(4, 2, 0), Real(3)); + EXPECT_EQ(values(4, 2, 1), Real(11)); + EXPECT_EQ(values(4, 2, 2), Real(-5)); + EXPECT_NEAR(values(4, 2, 3), Real(39), Real(1e-12)); + } +} + +TEST(ProgramContextContract, SystemExecutesScalarAxisPermutedPeriodicPlan) { + ensure_kokkos(); + SystemConfig cfg; + cfg.n = 6; + cfg.L = 1.0; + cfg.periodicity = {false, false}; + System sim(cfg); + const std::string state_identity = "case::block::scalar::state::U"; + sim.install_block_state_route("scalar", state_identity); + const PeriodicIdentification2D xlo_to_yhi{0, 3, std::array{{1, 0}}, + std::array{{1, 1}}}; + sim.install_boundary_plan( + "scalar", "case::block::scalar::boundary", 1, + {"periodic", "foextrap", "foextrap", "periodic"}, std::vector(4, 0.0), + {"case::block::scalar::xlo", "case::block::scalar::xhi", "case::block::scalar::ylo", + "case::block::scalar::yhi"}, + {"Scalar"}, {}, state_identity, PreparedBoundaryReadDependencies{}, {xlo_to_yhi}); + sim.install_block("scalar", 1, VariableSet{}, VariableSet{}, 1.0, BlockClosures{}, {}, {}, 1, + true, 1); + sim.mark_bound(); + + MultiFab& state = sim.block_state(0); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { values(i, j, 0) = Real(i + 100 * j); }); + } + const auto lane = ExecutionLane::world("test.system.axis-permuted-periodic"); + const runtime::multiblock::BoundaryEvaluationPoint point{ + "clock.system-axis-permuted", 0, 0, 0, 0, amr::Rational(0, 1), 0.1, 0.0}; + PreparedGridBoundarySession boundary(sim.grid_context("scalar"), lane, state, point); + boundary.fill(state, point); + device_fence(); + + for (int local = 0; local < state.local_size(); ++local) { + const Fab2D& field = state.fab(local); + const Box2D grown = field.grown_box(); + for (int j = 0; j < cfg.n; ++j) + if (grown.contains(-1, j)) + EXPECT_EQ(field(-1, j, 0), Real(j + 100 * (cfg.n - 1))); + for (int i = 0; i < cfg.n; ++i) + if (grown.contains(i, cfg.n)) + EXPECT_EQ(field(i, cfg.n, 0), Real(100 * i)); + } +} + TEST(ProgramContextContract, CommitManySnapshotsSourcesThatAreAlsoTargets) { ensure_kokkos(); SystemConfig cfg; diff --git a/tests/cpp/unit/runtime/test_scheme_dispatch.cpp b/tests/cpp/unit/runtime/test_scheme_dispatch.cpp index 268bb0cb9..5a9efbd5a 100644 --- a/tests/cpp/unit/runtime/test_scheme_dispatch.cpp +++ b/tests/cpp/unit/runtime/test_scheme_dispatch.cpp @@ -30,17 +30,21 @@ int routed_n_ghost(LimiterRouteId route) { } // namespace TEST(test_scheme_dispatch, routes_each_limiter_to_its_reconstruction_policy) { - // Each route binds the compile-time type whose ::n_ghost matches kLimiters (1/2/2/3) and the type in + // Each route binds the compile-time type whose ::n_ghost matches kLimiters and the type in // reconstruction.hpp -- so the X-macro POPS_FOR_EACH_LIMITER cannot drift from the route table. EXPECT_EQ(routed_n_ghost(LimiterRouteId::kNone), NoSlope::n_ghost); EXPECT_EQ(routed_n_ghost(LimiterRouteId::kMinmod), Minmod::n_ghost); EXPECT_EQ(routed_n_ghost(LimiterRouteId::kVanLeer), VanLeer::n_ghost); EXPECT_EQ(routed_n_ghost(LimiterRouteId::kWeno5), Weno5::n_ghost); + EXPECT_EQ(routed_n_ghost(LimiterRouteId::kMc), MC::n_ghost); + EXPECT_EQ(routed_n_ghost(LimiterRouteId::kSuperbee), Superbee::n_ghost); // Cross-check against the route table's ::n_ghost expectation (kLimiters, single source). EXPECT_EQ(routed_n_ghost(LimiterRouteId::kNone), 1); EXPECT_EQ(routed_n_ghost(LimiterRouteId::kMinmod), 2); EXPECT_EQ(routed_n_ghost(LimiterRouteId::kVanLeer), 2); EXPECT_EQ(routed_n_ghost(LimiterRouteId::kWeno5), 3); + EXPECT_EQ(routed_n_ghost(LimiterRouteId::kMc), 2); + EXPECT_EQ(routed_n_ghost(LimiterRouteId::kSuperbee), 2); } TEST(test_scheme_dispatch, count_lock_matches_the_route_table) { diff --git a/tests/cpp/unit/runtime/test_variable_role.cpp b/tests/cpp/unit/runtime/test_variable_role.cpp index e30c9195e..a34e7e293 100644 --- a/tests/cpp/unit/runtime/test_variable_role.cpp +++ b/tests/cpp/unit/runtime/test_variable_role.cpp @@ -30,3 +30,29 @@ TEST(VariableRole, IndexOfResolvesEulerIsothermalAndExBRoles) { << "roles isotherme"; EXPECT_EQ(pops::ExBVelocity::conservative_vars().index_of(R::Density), 0) << "role ExB"; } + +TEST(VariableRole, AxialRolesRoundTripThroughStableTextAbi) { + EXPECT_STREQ(pops::role_name(R::AxialX), "axial_x"); + EXPECT_STREQ(pops::role_name(R::AxialY), "axial_y"); + EXPECT_STREQ(pops::role_name(R::AxialZ), "axial_z"); + EXPECT_EQ(pops::role_from_name("axial_x"), R::AxialX); + EXPECT_EQ(pops::role_from_name("axial_y"), R::AxialY); + EXPECT_EQ(pops::role_from_name("axial_z"), R::AxialZ); + + const pops::VariableSet original{ + pops::VariableKind::Conservative, + {"rho", "bx", "by", "bz"}, + 4, + {R::Density, R::AxialX, R::AxialY, R::AxialZ}, + }; + EXPECT_EQ(pops::roles_csv(original), "density,axial_x,axial_y,axial_z"); + + pops::VariableSet restored{ + pops::VariableKind::Conservative, + original.names, + original.size, + }; + pops::parse_roles_into(restored, pops::roles_csv(original)); + EXPECT_TRUE(restored.user_roles.empty()); + EXPECT_EQ(restored.roles, original.roles); +} diff --git a/tests/gates/adc757_prepared_numerics.toml b/tests/gates/adc757_prepared_numerics.toml new file mode 100644 index 000000000..a31e190d0 --- /dev/null +++ b/tests/gates/adc757_prepared_numerics.toml @@ -0,0 +1,259 @@ +schema_version = 2 +gate = "adc757-prepared-numerics-slice" +issue = "ADC-757" +evidence_from = ["ADC-682", "ADC-749", "ADC-750", "ADC-751", "ADC-752", "ADC-753", "ADC-754", "ADC-755", "ADC-756"] +deferred = [ + "remaining_3d_metric_eb_characteristic_and_spatial_provider_matrix", + "remaining_legacy_recovery_and_boundary_authority_deletion", + "amr_regrid_migration_and_restart_coherence", + "gpu_backend_execution", + "accelerator_stream_partitioning", + "performance_baselines_and_end_to_end_benchmarks", + "local_time_and_load_balance_provider_families", +] + +# This is an executable partial gate, not ADC-757 closure. Every claimed +# requirement has one success proof and one refusal/detector proof. +[[check]] +requirement = "prepared_local_nonlinear" +polarity = "positive" +target = "test_newton_robustness" +test_regex = "^PreparedLocalNonlinear\\.FiniteDifferenceAnalyticAndAdUseOneOutcomeContract$" + +[[check]] +requirement = "prepared_local_nonlinear" +polarity = "refusal" +target = "test_newton_robustness" +test_regex = "^PreparedLocalNonlinear\\.EveryFailureClassIsExplicitAndLeavesTheGuessUntouched$" + +[[check]] +requirement = "typed_fallible_evaluation" +polarity = "positive" +target = "test_newton_robustness" +test_regex = "^PreparedLocalNonlinear\\.FallibleEvaluationStatusAndReasonRemainDistinct$" + +[[check]] +requirement = "typed_fallible_evaluation" +polarity = "refusal" +target = "test_prepared_numerics_gate" +test_regex = "^PreparedNumericsGate\\.FatalEvaluationIsTypedAllocationFreeAndCannotPublish$" + +[[check]] +requirement = "transactional_recovery_publication" +polarity = "positive" +target = "test_variable_recovery_chain" +test_regex = "^PreparedVariableRecovery\\.tentative_publication_rolls_back_solution_and_warm_start$" + +[[check]] +requirement = "transactional_recovery_publication" +polarity = "refusal" +target = "test_variable_recovery_chain" +test_regex = "^PreparedVariableRecovery\\.malformed_and_repair_candidates_fail_closed$" + +[[check]] +requirement = "model_declared_admissibility" +polarity = "positive" +target = "test_variable_recovery_chain" +test_regex = "^PreparedVariableRecovery\\.model_declared_admissibility_permits_valid_candidate$" + +[[check]] +requirement = "model_declared_admissibility" +polarity = "refusal" +target = "test_variable_recovery_chain" +test_regex = "^PreparedVariableRecovery\\.model_declared_admissibility_blocks_publication$" + +[[check]] +requirement = "allocation_aware_cell_hot_path" +polarity = "positive" +target = "test_prepared_numerics_gate" +test_regex = "^PreparedNumericsGate\\.ConvergedPreparedPathAllocatesNothingAndRollsBack$" + +[[check]] +requirement = "allocation_aware_cell_hot_path" +polarity = "refusal" +target = "test_prepared_numerics_gate" +test_regex = "^PreparedNumericsGate\\.AllocationProbeDetectsControlHeapTraffic$" + +[[check]] +requirement = "prepared_boundary_publication" +polarity = "positive" +target = "test_prepared_boundary_plan" +test_regex = "^test_prepared_boundary_plan\\.evaluates_prepared_coordinate_time_inflow_on_device_without_hot_path_allocation$" + +[[check]] +requirement = "prepared_boundary_publication" +polarity = "refusal" +target = "test_prepared_boundary_plan" +test_regex = "^test_prepared_boundary_plan\\.analytic_inflow_preflights_nonfinite_values_before_any_mutation$" + +[[check]] +requirement = "post_riemann_boundary_flux" +polarity = "positive" +target = "test_amr_native_loader" +test_regex = "^test_amr_native_loader\\.PostRiemannBoundaryFluxUsesOutwardOrientationAndPreservesCanonicalFaceStorage$" + +[[check]] +requirement = "post_riemann_boundary_flux" +polarity = "refusal" +kind = "pytest" +path = "tests/python/unit/mesh/test_boundary_topology_ports.py" +test = "test_post_riemann_flux_refuses_wrong_component_route_or_output" + +[[check]] +requirement = "mpi_collective_execution" +polarity = "positive" +kind = "mpi_ctest" +target = "test_mpi_system_analytic_level_set" +test_regex = "^test_mpi_system_analytic_level_set_np2$" +nproc = 2 + +[[check]] +requirement = "mpi_collective_execution" +polarity = "refusal" +kind = "mpi_ctest" +target = "test_mpi_flux_failure_collective" +test_regex = "^test_mpi_flux_failure_collective_np2$" +nproc = 2 + +[[check]] +requirement = "qualified_flux_provider_pack" +polarity = "positive" +target = "test_flux_interfaces" +test_regex = "^test_flux_interfaces\\.generated_provider_requirements_own_native_slot_reads$" + +[[check]] +requirement = "qualified_flux_provider_pack" +polarity = "refusal" +kind = "pytest" +path = "tests/python/unit/codegen/test_compiler_model_provider.py" +test = "test_field_dependent_flux_without_provider_fails_before_native_source" + +[[check]] +requirement = "capability_driven_riemann" +polarity = "positive" +target = "test_riemann_capabilities" +test_regex = "^test_riemann_capabilities\\.state_layout_permutation_is_provider_owned$" + +[[check]] +requirement = "capability_driven_riemann" +polarity = "refusal" +target = "test_flux_interfaces" +test_regex = "^test_flux_interfaces\\.hllc_rejects_each_nonfinite_provider_stage_with_a_typed_cause$" + +[[check]] +requirement = "capability_driven_riemann" +polarity = "refusal" +target = "test_flux_interfaces" +test_regex = "^test_flux_interfaces\\.roe_rejects_nonfinite_dissipation_with_a_typed_cause$" + +[[check]] +requirement = "prepared_limiter_provider" +polarity = "positive" +target = "test_weno_convergence" +test_regex = "^test_muscl_limiters\\.mc_and_superbee_match_reference_formulas$" + +[[check]] +requirement = "prepared_limiter_provider" +polarity = "positive" +target = "test_weno_convergence" +test_regex = "^test_muscl_limiter_qualification\\.mc_and_superbee_are_second_order_on_smooth_periodic_cell_averages$" + +[[check]] +requirement = "prepared_limiter_provider" +polarity = "positive" +target = "test_weno_convergence" +test_regex = "^test_muscl_limiter_qualification\\.discontinuities_create_no_extremum_and_expose_interface_dissipation_budget$" + +[[check]] +requirement = "prepared_limiter_provider" +polarity = "refusal" +target = "test_dispatch_tags" +test_regex = "^test_dispatch_tags\\.validate_limiter_accepts_and_rejects$" + +[[check]] +requirement = "typed_flux_recovery_consumption" +polarity = "positive" +target = "test_flux_interfaces" +test_regex = "^test_flux_interfaces\\.recovery_report_uses_the_flux_failure_reduction_without_type_erasure$" + +[[check]] +requirement = "typed_flux_recovery_consumption" +polarity = "refusal" +target = "test_flux_interfaces" +test_regex = "^test_flux_interfaces\\.face_recovery_refusal_never_reaches_the_numerical_flux$" + +[[check]] +requirement = "runtime_recovery_consumer_publication" +polarity = "positive" +target = "test_block_builder" +test_regex = "^test_block_builder\\.cell_primitive_conversion_consumes_prepared_recovery_outcome$" + +[[check]] +requirement = "runtime_recovery_consumer_publication" +polarity = "refusal" +target = "test_facade_routing" +test_regex = "^FacadeRouting\\.PrimitiveMaterializationFailsClosedWithoutMutatingAcceptedState$" + +[[check]] +requirement = "type_erased_recovery_method_identity" +polarity = "positive" +target = "test_variable_recovery_chain" +test_regex = "^PreparedVariableRecovery\\.type_erased_report_preserves_selected_method_kind$" + +[[check]] +requirement = "type_erased_recovery_method_identity" +polarity = "refusal" +target = "test_variable_recovery_chain" +test_regex = "^PreparedVariableRecovery\\.rejected_report_names_last_method_without_forging_selection$" + +[[check]] +requirement = "cell_local_temporal_partition_authority" +polarity = "positive" +target = "test_temporal_partition_restart" +test_regex = "^test_temporal_partition_restart\\.batched_attempt_commit_and_rollback_are_exact$" + +[[check]] +requirement = "cell_local_temporal_partition_authority" +polarity = "refusal" +target = "test_temporal_partition_restart" +test_regex = "^test_temporal_partition_restart\\.strict_amr_restore_consumes_manifest_and_refuses_global_step_bypass$" + +[[check]] +requirement = "host_workspace_reentrancy" +polarity = "positive" +target = "test_krylov_workspace_reentrancy" +test_regex = "^test_krylov_workspace_reentrancy\\.distinct_workspaces_run_fresh_operator_and_preconditioner_sessions_concurrently$" + +[[check]] +requirement = "host_workspace_reentrancy" +polarity = "refusal" +target = "test_krylov_workspace_reentrancy" +test_regex = "^test_krylov_workspace_reentrancy\\.workspace_rebind_reserves_mutation_during_blocking_operator_prepare$" + +[[check]] +requirement = "python_ir_generated_abi_and_restart_parity" +polarity = "positive" +kind = "pytest" +path = "tests/python/unit/codegen/test_recovery_admissibility_codegen.py" +test = "test_recovery_admissibility_is_emitted_and_hashed" + +[[check]] +requirement = "python_ir_generated_abi_and_restart_parity" +polarity = "refusal" +kind = "pytest" +path = "tests/python/unit/codegen/test_recovery_admissibility_codegen.py" +test = "test_recovery_admissibility_rejects_ambiguous_authoring" + +[[check]] +requirement = "python_ir_generated_abi_and_restart_parity" +polarity = "positive" +kind = "pytest" +path = "tests/python/unit/runtime/test_amr_checkpoint_contract.py" +test = "test_preflight_returns_exact_native_payload_and_counters" + +[[check]] +requirement = "python_ir_generated_abi_and_restart_parity" +polarity = "refusal" +kind = "pytest" +path = "tests/python/unit/runtime/test_amr_checkpoint_contract.py" +test = "test_historical_version_refusal_happens_before_restart_transaction" diff --git a/tests/python/architecture/test_adc757_prepared_numerics_gate.py b/tests/python/architecture/test_adc757_prepared_numerics_gate.py new file mode 100644 index 000000000..0d2ed5316 --- /dev/null +++ b/tests/python/architecture/test_adc757_prepared_numerics_gate.py @@ -0,0 +1,336 @@ +"""Source-only integrity checks for the bounded ADC-757 numerical gate.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +ROOT = Path(__file__).resolve().parents[3] +MANIFEST = ROOT / "tests/gates/adc757_prepared_numerics.toml" +RUNNER = ROOT / "scripts/run_adc757_prepared_numerics_gate.py" + + +def _load_runner(): + spec = importlib.util.spec_from_file_location("pops_run_adc757_gate", RUNNER) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_adc757_slice_references_exact_real_mandatory_native_proofs(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors, "ADC-757 slice matrix is invalid:\n " + "\n ".join(errors) + assert len(data["check"]) == 39 + assert {row["requirement"] for row in data["check"]} == runner.EXPECTED_REQUIREMENTS + assert data["evidence_from"] == [ + "ADC-682", + "ADC-749", + "ADC-750", + "ADC-751", + "ADC-752", + "ADC-753", + "ADC-754", + "ADC-755", + "ADC-756", + ] + assert runner.main(["--check-only"]) == 0 + + +def test_adc757_slice_executes_qualified_flux_provider_pack_proofs(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors + assert [ + row for row in data["check"] if row["requirement"] == "qualified_flux_provider_pack" + ] == [ + { + "requirement": "qualified_flux_provider_pack", + "polarity": "positive", + "target": "test_flux_interfaces", + "test_regex": "^test_flux_interfaces\\." + "generated_provider_requirements_own_native_slot_reads$", + }, + { + "requirement": "qualified_flux_provider_pack", + "polarity": "refusal", + "kind": "pytest", + "path": "tests/python/unit/codegen/test_compiler_model_provider.py", + "test": "test_field_dependent_flux_without_provider_fails_before_native_source", + }, + ] + + +def test_adc757_slice_executes_post_riemann_boundary_flux_proofs(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors + assert [ + row for row in data["check"] + if row["requirement"] == "post_riemann_boundary_flux" + ] == [ + { + "requirement": "post_riemann_boundary_flux", + "polarity": "positive", + "target": "test_amr_native_loader", + "test_regex": "^test_amr_native_loader\\." + "PostRiemannBoundaryFluxUsesOutwardOrientationAndPreservesCanonicalFaceStorage$", + }, + { + "requirement": "post_riemann_boundary_flux", + "polarity": "refusal", + "kind": "pytest", + "path": "tests/python/unit/mesh/test_boundary_topology_ports.py", + "test": "test_post_riemann_flux_refuses_wrong_component_route_or_output", + }, + ] + + +def test_adc757_slice_claims_only_the_exact_delivered_mpi_collective_proof(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors + assert data["deferred"] == list(runner.EXPECTED_DEFERRED) + assert "mpi_collective_execution" not in data["deferred"] + assert "gpu_backend_execution" in data["deferred"] + assert "accelerator_stream_partitioning" in data["deferred"] + assert "workspace_reentrancy_and_stream_partitioning" not in data["deferred"] + assert "remaining_legacy_recovery_and_boundary_authority_deletion" in data["deferred"] + assert all("riemann_authority" not in family for family in data["deferred"]) + assert "runtime_consumer_cutover_and_legacy_deletion" not in data["deferred"] + assert "boundary_geometry_riemann_and_spatial_provider_families" not in data["deferred"] + assert [ + row for row in data["check"] if row.get("kind") == "mpi_ctest" + ] == [ + { + "requirement": "mpi_collective_execution", + "polarity": "positive", + "kind": "mpi_ctest", + "target": "test_mpi_system_analytic_level_set", + "test_regex": "^test_mpi_system_analytic_level_set_np2$", + "nproc": 2, + }, + { + "requirement": "mpi_collective_execution", + "polarity": "refusal", + "kind": "mpi_ctest", + "target": "test_mpi_flux_failure_collective", + "test_regex": "^test_mpi_flux_failure_collective_np2$", + "nproc": 2, + }, + ] + assert all( + "gpu" not in row.get("target", row.get("path", "")).lower() + for row in data["check"] + ) + assert runner.main(["--check-only", "--closure"]) == 3 + + +def test_adc757_slice_executes_host_workspace_reentrancy_without_claiming_streams(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors + assert [ + row for row in data["check"] if row["requirement"] == "host_workspace_reentrancy" + ] == [ + { + "requirement": "host_workspace_reentrancy", + "polarity": "positive", + "target": "test_krylov_workspace_reentrancy", + "test_regex": "^test_krylov_workspace_reentrancy\\." + "distinct_workspaces_run_fresh_operator_and_preconditioner_sessions_concurrently$", + }, + { + "requirement": "host_workspace_reentrancy", + "polarity": "refusal", + "target": "test_krylov_workspace_reentrancy", + "test_regex": "^test_krylov_workspace_reentrancy\\." + "workspace_rebind_reserves_mutation_during_blocking_operator_prepare$", + }, + ] + + +def test_adc757_slice_executes_exact_python_ir_and_restart_proofs(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors + assert [ + row + for row in data["check"] + if row.get("kind") == "pytest" + and row["requirement"] == "python_ir_generated_abi_and_restart_parity" + ] == [ + { + "requirement": "python_ir_generated_abi_and_restart_parity", + "polarity": "positive", + "kind": "pytest", + "path": "tests/python/unit/codegen/test_recovery_admissibility_codegen.py", + "test": "test_recovery_admissibility_is_emitted_and_hashed", + }, + { + "requirement": "python_ir_generated_abi_and_restart_parity", + "polarity": "refusal", + "kind": "pytest", + "path": "tests/python/unit/codegen/test_recovery_admissibility_codegen.py", + "test": "test_recovery_admissibility_rejects_ambiguous_authoring", + }, + { + "requirement": "python_ir_generated_abi_and_restart_parity", + "polarity": "positive", + "kind": "pytest", + "path": "tests/python/unit/runtime/test_amr_checkpoint_contract.py", + "test": "test_preflight_returns_exact_native_payload_and_counters", + }, + { + "requirement": "python_ir_generated_abi_and_restart_parity", + "polarity": "refusal", + "kind": "pytest", + "path": "tests/python/unit/runtime/test_amr_checkpoint_contract.py", + "test": "test_historical_version_refusal_happens_before_restart_transaction", + }, + ] + assert "python_ir_generated_abi_and_restart_parity" not in data["deferred"] + + +def test_adc757_manifest_refuses_missing_polarity_and_unknown_target(tmp_path): + runner = _load_runner() + source = MANIFEST.read_text(encoding="utf-8") + + missing_refusal = tmp_path / "missing_refusal.toml" + missing_refusal.write_text( + source.replace('polarity = "refusal"', 'polarity = "positive"', 1), + encoding="utf-8", + ) + _, errors = runner.validate_manifest(missing_refusal) + assert any("lacks refusal coverage" in error for error in errors) + + unknown_target = tmp_path / "unknown_target.toml" + unknown_target.write_text( + source.replace( + 'target = "test_newton_robustness"', + 'target = "test_missing_provider_proof"', + 1, + ), + encoding="utf-8", + ) + _, errors = runner.validate_manifest(unknown_target) + assert any("unknown CTest target" in error for error in errors) + + wrong_mpi_rank = tmp_path / "wrong_mpi_rank.toml" + wrong_mpi_rank.write_text( + source.replace("nproc = 2", "nproc = 4", 1), + encoding="utf-8", + ) + _, errors = runner.validate_manifest(wrong_mpi_rank) + assert any("one exact rank count" in error for error in errors) + + unknown_python_file = tmp_path / "unknown_python_file.toml" + unknown_python_file.write_text( + source.replace( + 'path = "tests/python/unit/codegen/test_recovery_admissibility_codegen.py"', + 'path = "tests/python/unit/codegen/test_missing_gate_proof.py"', + 1, + ), + encoding="utf-8", + ) + _, errors = runner.validate_manifest(unknown_python_file) + assert any("unknown Python test file" in error for error in errors) + + unknown_pytest = tmp_path / "unknown_pytest.toml" + unknown_pytest.write_text( + source.replace( + 'test = "test_recovery_admissibility_is_emitted_and_hashed"', + 'test = "test_missing_gate_proof"', + 1, + ), + encoding="utf-8", + ) + _, errors = runner.validate_manifest(unknown_pytest) + assert any("unknown top-level pytest" in error for error in errors) + + skipped = runner.ast.parse( + "@pytest.mark.xfail\ndef test_skipped():\n pass\n" + ).body[0] + assert runner._pytest_is_skipped(skipped) + + skipped_ctest = tmp_path / "skipped_ctest.toml" + skipped_ctest.write_text( + source.replace( + "^test_krylov_workspace_reentrancy\\\\." + "distinct_workspaces_run_fresh_operator_and_preconditioner_sessions_concurrently$", + "^test_krylov_workspace_reentrancy\\\\." + "rank_local_problem_construction_failure_is_published_before_lane_unwind$", + 1, + ), + encoding="utf-8", + ) + _, errors = runner.validate_manifest(skipped_ctest) + assert any("selected CTest" in error and "skipped or disabled" in error for error in errors) + + +def test_adc757_runner_refuses_a_declared_but_unbuilt_proof(monkeypatch, tmp_path): + runner = _load_runner() + + def empty_ctest_listing(command, **kwargs): + assert command[:2] == ["ctest", "--test-dir"] + return SimpleNamespace(returncode=0, stdout="Total Tests: 0\n") + + monkeypatch.setattr(runner.subprocess, "run", empty_ctest_listing) + with pytest.raises(RuntimeError, match="is not built"): + runner._run_ctest( + tmp_path, + "test_prepared_numerics_gate", + r"^PreparedNumericsGate\.ConvergedPreparedPathAllocatesNothingAndRollsBack$", + ) + + +def test_adc757_runner_executes_one_exact_pytest(monkeypatch): + runner = _load_runner() + calls = [] + + def capture_pytest(command, **kwargs): + report = Path(command[command.index("--junitxml") + 1]) + report.write_text("", encoding="utf-8") + calls.append((command, kwargs)) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(runner.subprocess, "run", capture_pytest) + runner._run_pytest( + "tests/python/unit/codegen/test_recovery_admissibility_codegen.py", + "test_recovery_admissibility_is_emitted_and_hashed", + ) + [(command, kwargs)] = calls + assert command[:4] == [runner.sys.executable, "-m", "pytest", "-q"] + assert command[4:8] == ["--strict-markers", "-o", "xfail_strict=true", "--junitxml"] + assert command[-1] == ( + "tests/python/unit/codegen/test_recovery_admissibility_codegen.py::" + "test_recovery_admissibility_is_emitted_and_hashed" + ) + assert kwargs["cwd"] == runner.ROOT + assert kwargs["check"] is False + assert kwargs["env"]["POPS_REQUIRE_MPI_TESTS"] == "1" + assert kwargs["env"]["POPS_REQUIRE_NATIVE_TESTS"] == "1" + + +def test_adc757_runner_refuses_a_runtime_skip(monkeypatch): + runner = _load_runner() + + def skipped_pytest(command, **kwargs): + report = Path(command[command.index("--junitxml") + 1]) + report.write_text( + "", + encoding="utf-8", + ) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(runner.subprocess, "run", skipped_pytest) + with pytest.raises(RuntimeError, match="skipped/xfail proof"): + runner._run_pytest( + "tests/python/unit/codegen/test_recovery_admissibility_codegen.py", + "test_recovery_admissibility_is_emitted_and_hashed", + ) diff --git a/tests/python/architecture/test_ci_impacted_selection.py b/tests/python/architecture/test_ci_impacted_selection.py index de21b95a7..01384ee1e 100644 --- a/tests/python/architecture/test_ci_impacted_selection.py +++ b/tests/python/architecture/test_ci_impacted_selection.py @@ -320,7 +320,7 @@ def test_manifest_projects_exact_mpi_targets_for_dedicated_job(): for suite in all_suites ) ctest_plan = sel.cpp_mpi_ctest_plan(manifest) - assert len(ctest_plan) == sel.cpp_mpi_ctest_count(manifest) == expected_count == 80 + assert len(ctest_plan) == sel.cpp_mpi_ctest_count(manifest) == expected_count == 83 assert ctest_plan["test_mpi_external_lifecycle_np1"] == 1 assert ctest_plan["test_mpi_hdf5_collective_np2"] == 2 assert ctest_plan["test_mpi_amr_compiled_parity_rank_parity"] == 4 diff --git a/tests/python/architecture/test_final_public_api.py b/tests/python/architecture/test_final_public_api.py index 1d3e8257d..58d4f7014 100644 --- a/tests/python/architecture/test_final_public_api.py +++ b/tests/python/architecture/test_final_public_api.py @@ -325,8 +325,8 @@ def test_physics_has_no_competing_model_facade() -> None: from pops import physics assert physics.__all__ == [ - "Model", "ComponentRole", "Density", "Energy", "Momentum", "Pressure", "Scalar", - "Temperature", "Velocity", + "Model", "Axial", "ComponentRole", "Density", "Energy", "Momentum", "Pressure", + "Scalar", "Temperature", "Velocity", ] assert physics.Model is pops.Model for removed in ("PdeModel", "HyperbolicModel", "PhysicsModel", "HybridModel"): diff --git a/tests/python/architecture/test_flux_interface_fences.py b/tests/python/architecture/test_flux_interface_fences.py index 1083f28e5..e9e3dd358 100644 --- a/tests/python/architecture/test_flux_interface_fences.py +++ b/tests/python/architecture/test_flux_interface_fences.py @@ -48,6 +48,14 @@ def test_bound_native_flux_pack_is_exact_and_does_not_store_global_aux(): assert "FluxDensity checked_density() const" in header +def test_generated_flux_pack_metadata_controls_native_storage_reads(): + header = _behavior(ROOT / "include/pops/numerics/fv/flux_interfaces.hpp") + assert "qualified_flux_provider_requirements_valid" in header + assert "qualified_flux_provider_storage_slot" in header + assert "std::make_index_sequence" in header + assert "generated physical flux provider requirements are invalid" in header + + def test_provider_selection_is_qualified_and_never_returns_a_neutral_value(): source = (ROOT / "python/pops/model/provider_pack.py").read_text(encoding="utf-8") assert "def select(" in source @@ -55,3 +63,43 @@ def test_provider_selection_is_qualified_and_never_returns_a_neutral_value(): assert "owner_qid" in source assert "return 0" not in source assert "return 0.0" not in source + + +def test_hllc_rejects_nonfinite_provider_stages_before_publication(): + policy = _behavior(ROOT / "include/pops/numerics/fv/numerical_flux.hpp") + interface = _behavior(ROOT / "include/pops/numerics/fv/flux_interfaces.hpp") + hllc = policy.split("struct HLLCFlux", 1)[1].split( + "concept RoePhysicalFlux", 1 + )[0] + causes = ( + "kHllcNonFinitePhysicalFlux", + "kHllcNonFinitePressure", + "kHllcNonFiniteContact", + "kHllcNonFiniteStarState", + "kHllcNonFiniteFlux", + ) + + for cause in causes: + assert cause in interface + assert cause in hllc + assert hllc.count("detail::finite_state") >= 6 + assert hllc.count("Kokkos::isfinite") >= 2 + + +def test_capability_driven_riemann_has_no_euler_specific_production_authority(): + production_roots = (ROOT / "include/pops", ROOT / "src", ROOT / "python/pops") + sources = ( + path + for root in production_roots + for path in root.rglob("*") + if path.suffix in {".hpp", ".cpp", ".py"} + ) + production = "\n".join(path.read_text(encoding="utf-8") for path in sources) + + for retired_authority in ( + "EulerHLLCFlux2D", + "EulerRoeFlux2D", + "euler_hllc", + "euler_roe", + ): + assert retired_authority not in production diff --git a/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py b/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py new file mode 100644 index 000000000..1c5916246 --- /dev/null +++ b/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py @@ -0,0 +1,204 @@ +"""ADC-749: legacy transport-boundary authorities may only disappear. + +The prepared hyperbolic boundary path is already the compiled Uniform/AMR +route. A few older native authorities still exist while their replacements +need metric and characteristic kernels. Keep their remaining +lexical surface bounded so adjacent work cannot silently create another +transport-boundary engine before that cutover is complete. +""" + +from __future__ import annotations + +import ast +import json +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +PRODUCTION_ROOTS = (ROOT / "include/pops", ROOT / "src/runtime") + +# Exact upper bounds on the consolidated ADC-749 branch. Counts deliberately +# include comments: closure requires deleting the legacy vocabulary as well as +# its executable branches. A deletion passes without editing this ledger; +# any new file or additional occurrence fails closed. +LEGACY_AUTHORITY_LIMITS = { + "AmrBoundaryFillAuthority": { + "include/pops/coupling/amr/amr_coupler_mp.hpp": 2, + "include/pops/numerics/time/amr/levels/amr_subcycling.hpp": 6, + "include/pops/runtime/amr/amr_runtime.hpp": 1, + "include/pops/runtime/builders/compiled/amr_dsl_block.hpp": 1, + }, + "make_amr_boundary_fill_authority": { + "include/pops/numerics/time/amr/levels/amr_subcycling.hpp": 1, + "include/pops/runtime/builders/compiled/amr_dsl_block.hpp": 1, + }, + "wall_radial": { + "include/pops/numerics/spatial/operators/polar_operator.hpp": 9, + "include/pops/runtime/builders/block/block_builder_polar.hpp": 13, + "src/runtime/system/system_polar.cpp": 2, + }, + "fill_ghosts_polar": { + "include/pops/runtime/builders/block/block_builder_polar.hpp": 3, + }, + "transport_bc": { + "include/pops/runtime/amr/amr_runtime.hpp": 3, + "include/pops/runtime/builders/compiled/amr_dsl_block.hpp": 7, + "include/pops/runtime/program/amr_program_context.hpp": 2, + }, +} + + +def _production_sources() -> tuple[Path, ...]: + return tuple( + sorted( + path + for root in PRODUCTION_ROOTS + for path in root.rglob("*") + if path.suffix in {".cpp", ".hpp"} + ) + ) + + +def _occurrences() -> dict[str, dict[str, int]]: + patterns = { + identifier: re.compile(r"\b%s\b" % re.escape(identifier)) + for identifier in LEGACY_AUTHORITY_LIMITS + } + counts = {identifier: {} for identifier in patterns} + for path in _production_sources(): + source = path.read_text(encoding="utf-8") + relative = path.relative_to(ROOT).as_posix() + for identifier, pattern in patterns.items(): + count = len(pattern.findall(source)) + if count: + counts[identifier][relative] = count + return counts + + +def test_legacy_transport_boundary_authorities_can_only_shrink() -> None: + occurrences = _occurrences() + violations = [] + for identifier, limits in LEGACY_AUTHORITY_LIMITS.items(): + for path, count in occurrences[identifier].items(): + limit = limits.get(path, 0) + if count > limit: + violations.append( + "%s: %s has %d occurrence(s), allowed at most %d" + % (identifier, path, count, limit) + ) + + assert not violations, ( + "legacy transport-boundary authority expanded; lower the route to " + "PreparedBoundaryPlan instead:\n " + "\n ".join(violations) + ) + + +def test_resolved_transport_authority_accepts_only_executable_descriptors() -> None: + """Numerical resolution, rather than a later compile/bind phase, owns acceptance.""" + source = ROOT / "python/pops/boundary/transport.py" + tree = ast.parse(source.read_text(encoding="utf-8")) + authority = next( + node for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == "ResolvedTransportBoundarySet" + ) + post_init = next( + node for node in authority.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == "__post_init__" + ) + calls = { + node.func.attr + for node in ast.walk(post_init) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) and node.func.value.id == "self" + } + assert "_native_contract" in calls, ( + "ResolvedTransportBoundarySet must authenticate the complete executable boundary " + "contract during numerical resolution; do not defer unsupported descriptors to compile" + ) + + +def test_boundary_provider_identity_cannot_erase_its_selected_law() -> None: + """Every provider identity must retain the law selected by its public factory.""" + source = ROOT / "python/pops/mesh/boundaries/providers.py" + tree = ast.parse(source.read_text(encoding="utf-8")) + provider = next( + node for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == "BoundaryProvider" + ) + annotations = { + node.target.id + for node in provider.body + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) + } + assert "kind" in annotations, "BoundaryProvider must retain one immutable typed law" + canonical = next( + node for node in provider.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == "canonical_identity" + ) + keys = { + node.value for node in ast.walk(canonical) + if isinstance(node, ast.Constant) and isinstance(node.value, str) + } + assert "provider_kind" in keys, ( + "BoundaryProvider canonical identity must authenticate its selected law" + ) + + +def test_post_riemann_flux_is_one_typed_outward_oriented_pipeline_stage() -> None: + catalog = json.loads( + (ROOT / "schemas/component_catalog.v2.json").read_text(encoding="utf-8") + ) + interface = next( + row for row in catalog["native_interface_abis"] + if row["name"] == "boundary_flux" + ) + assert interface["cpp_table"] == "PopsBoundaryFluxApiV1" + assert interface["operations"] == ["transform_faces"] + route = catalog["boundary_handle_native_routes"]["boundary_flux_provider"] + assert route["interface"] == "boundary_flux" + assert route["operation"] == "transform_faces" + + executor = ( + ROOT / "include/pops/mesh/boundary/boundary_component_executor.hpp" + ).read_text(encoding="utf-8") + assert re.search( + r"const double outward\s*=\s*static_cast\(workspace\.side\)\s*\*", + executor, + ) + assert re.search( + r"static_cast\(static_cast\(workspace\.side\)\s*\*\s*outward\)", + executor, + ) + + uniform = ( + ROOT / "include/pops/runtime/builders/block/block_builder.hpp" + ).read_text(encoding="utf-8") + uniform_stage = uniform[ + uniform.index("assemble_rhs_without_prepared_interfaces"): + uniform.index("struct BlockRhsEval") + ] + assert uniform_stage.index("compute_face_fluxes") < uniform_stage.index( + "transform_grid_boundary_fluxes" + ) < uniform_stage.index("mf_eval_rhs") + unqualified = uniform[ + uniform.index("void operator()(MultiFab& U, MultiFab& R) const"): + uniform.index( + "void operator()(const runtime::multiblock::BoundaryEvaluationPoint& point", + ) + ] + assert "has_flux_transformations()" in unqualified + assert "requires a BoundaryEvaluationPoint" in unqualified + assert "has_omitted_faces()" in unqualified + assert "shared-interface flux requires BoundaryEvaluationPoint group authority" in unqualified + assert "eval_core_filled(U, R);" in unqualified + + amr = ( + ROOT / "include/pops/runtime/builders/compiled/amr_dsl_block.hpp" + ).read_text(encoding="utf-8") + first_flux = amr.index("detail::compute_amr_face_fluxes") + first_transform = amr.index("transform_grid_boundary_fluxes", first_flux) + first_divergence = amr.index("pops::mf_eval_rhs", first_transform) + assert first_flux < first_transform < first_divergence diff --git a/tests/python/architecture/test_import_graph.py b/tests/python/architecture/test_import_graph.py index 0721ae577..428a03691 100644 --- a/tests/python/architecture/test_import_graph.py +++ b/tests/python/architecture/test_import_graph.py @@ -15,7 +15,7 @@ mesh -> analytic, domain, frames, identity, model, params amr -> _ir, identity, mesh, model, time layouts -> amr, mesh - boundary -> _ir, domain, identity, model, representations + boundary -> _ir, analytic, domain, identity, model, representations numerics -> identity, model, params linalg -> (nothing) (Spec 5: abstract algebra descriptors) solvers -> identity (typed solver descriptor sink) @@ -63,7 +63,7 @@ "mesh": {"analytic", "domain", "frames", "identity", "model", "params"}, "amr": {"_ir", "identity", "mesh", "model", "time"}, "layouts": {"amr", "mesh"}, - "boundary": {"_ir", "domain", "identity", "model", "representations"}, + "boundary": {"_ir", "analytic", "domain", "identity", "model", "representations"}, "numerics": {"identity", "model", "params"}, "solvers": {"identity"}, "fields": {"_ir", "identity", "model", "time"}, diff --git a/tests/python/architecture/test_prepared_boundary_linearization_authority.py b/tests/python/architecture/test_prepared_boundary_linearization_authority.py new file mode 100644 index 000000000..49bbf7d4c --- /dev/null +++ b/tests/python/architecture/test_prepared_boundary_linearization_authority.py @@ -0,0 +1,33 @@ +"""The System boundary linearization has one prepared execution authority.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +SYSTEM_PROGRAM = ROOT / "src" / "runtime" / "system" / "system_program.cpp" + + +def _first_overload(source: str, signature: str) -> str: + start = source.index(signature) + end = source.index(signature, start + len(signature)) + return source[start:end] + + +def test_boundary_residual_refuses_a_missing_prepared_session(): + source = SYSTEM_PROGRAM.read_text(encoding="utf-8") + body = _first_overload(source, "void System::block_boundary_residual_into_at(") + + assert "if (!block.boundary_session)" in body + assert "persistent prepared boundary session" in body + assert "block.boundary_residual_at_point_prepared(" in body + assert "block.boundary_residual_at_point;" not in body + + +def test_boundary_jvp_refuses_a_missing_prepared_session(): + source = SYSTEM_PROGRAM.read_text(encoding="utf-8") + body = _first_overload(source, "void System::block_boundary_jvp_into_at(") + + assert "if (!block.boundary_session)" in body + assert "persistent prepared boundary session" in body + assert "block.boundary_jvp_at_point_prepared(" in body + assert "block.boundary_jvp_at_point;" not in body diff --git a/tests/python/architecture/test_prepared_local_nonlinear_authority.py b/tests/python/architecture/test_prepared_local_nonlinear_authority.py new file mode 100644 index 000000000..0ba426ee8 --- /dev/null +++ b/tests/python/architecture/test_prepared_local_nonlinear_authority.py @@ -0,0 +1,125 @@ +"""ADC-750 fences for the sole prepared local nonlinear solver authority.""" + +from __future__ import annotations + +import ast +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +PROVIDER = ROOT / "include/pops/numerics/nonlinear/prepared_local_nonlinear.hpp" +IMPLICIT_STEPPER = ROOT / "include/pops/numerics/time/integrators/implicit_stepper.hpp" +MODEL_KERNELS = ROOT / "python/pops/codegen/program_emit_model_kernels.py" + + +def _without_cpp_comments(source: str) -> str: + return re.sub(r"//.*?$|/\*.*?\*/", "", source, flags=re.MULTILINE | re.DOTALL) + + +def _cpp_body(source: str, signature: str) -> str: + start = source.index(signature) + opening = source.index("{", start + len(signature)) + depth = 0 + for index in range(opening, len(source)): + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + if depth == 0: + return source[opening + 1 : index] + raise AssertionError(f"unterminated C++ body for {signature!r}") + + +def _python_function_source(path: Path, name: str) -> str: + source = path.read_text(encoding="utf-8") + module = ast.parse(source) + functions = [ + node + for node in module.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name + ] + assert len(functions) == 1 + function = functions[0] + assert function.end_lineno is not None + return "\n".join(source.splitlines()[function.lineno - 1 : function.end_lineno]) + + +def test_prepared_provider_is_the_only_local_nonlinear_algorithm_definition(): + definition = re.compile( + r"LocalNonlinearCellResult\s+solve_prepared_local_nonlinear\s*\(" + ) + definitions = [ + path.relative_to(ROOT).as_posix() + for path in sorted((ROOT / "include").rglob("*.hpp")) + if definition.search(_without_cpp_comments(path.read_text(encoding="utf-8"))) + ] + assert definitions == ["include/pops/numerics/nonlinear/prepared_local_nonlinear.hpp"] + + provider = _without_cpp_comments(PROVIDER.read_text(encoding="utf-8")) + solve = _cpp_body(provider, "solve_prepared_local_nonlinear(") + assert "pivoted_dense_solve" in solve + assert "build_local_jacobian" in solve + assert "LocalNonlinearCellResult result;" in solve + for forbidden in ( + "mat_inverse", + "std::function", + "std::vector", + "std::unique_ptr", + "std::shared_ptr", + "malloc(", + "calloc(", + "realloc(", + "throw ", + ): + assert forbidden not in solve + + +def test_generated_program_routes_delegate_instead_of_emitting_newton(): + for function_name in ( + "_emit_solve_coupled_implicit_kernel", + "_emit_solve_local_nonlinear_kernel", + ): + source = _python_function_source(MODEL_KERNELS, function_name) + assert source.count("solve_prepared_local_nonlinear") == 1 + for forbidden in ( + "mat_inverse", + "pivoted_dense_solve", + "build_local_jacobian", + "for (int newton", + "for (int iteration", + ): + assert forbidden not in source + + +def test_implicit_source_device_kernel_is_a_stack_only_provider_adapter(): + source = _without_cpp_comments(IMPLICIT_STEPPER.read_text(encoding="utf-8")) + adapter = source.split("struct PreparedImplicitSourceKernel", 1)[1].split( + "struct LocalStatMax", 1 + )[0] + kernel = _cpp_body(adapter, "POPS_HD void operator()(int i, int j) const") + assert kernel.count("solve_prepared_local_nonlinear") == 1 + assert "LocalNonlinearCellResult solved;" in kernel + for forbidden in ( + "mat_inverse", + "pivoted_dense_solve", + "build_local_jacobian", + "std::function", + "std::vector", + "std::unique_ptr", + "std::shared_ptr", + "malloc(", + "calloc(", + "realloc(", + "throw ", + ): + assert forbidden not in kernel + + +def test_implicit_source_publication_consumes_one_collective_outcome(): + source = _without_cpp_comments(IMPLICIT_STEPPER.read_text(encoding="utf-8")) + publication = _cpp_body(source, "const MultiFab* active_cells = nullptr)") + assert publication.count("PreparedImplicitSourceKernel") == 1 + assert publication.count("SolveOutcome::collective_world") == 1 + assert "ImplicitSourcePublication" in publication + assert "solved_value_available()" not in publication diff --git a/tests/python/architecture/test_program_only_temporal_facades.py b/tests/python/architecture/test_program_only_temporal_facades.py index 4e1cbc5c7..dfc8f1e43 100644 --- a/tests/python/architecture/test_program_only_temporal_facades.py +++ b/tests/python/architecture/test_program_only_temporal_facades.py @@ -47,6 +47,7 @@ IMPLICIT_STEPPER = ROOT / "include/pops/numerics/time/integrators/implicit_stepper.hpp" SYSTEM_IMPL = ROOT / "src/runtime/system/system_impl.hpp" SYSTEM_INSTALL = ROOT / "src/runtime/system/system_install.cpp" +PYTHON_SYSTEM_INSTALL = ROOT / "python/pops/runtime/_system_install.py" BINDINGS_DETAIL = ROOT / "python/bindings/core/bindings_detail.hpp" AMR_BINDING = ROOT / "python/bindings/core/init/init_amr.cpp" LEGACY_AMR_ADVANCE_HEADER = ROOT / "include/pops/numerics/time/amr/advance/amr_advance.hpp" @@ -352,6 +353,24 @@ def test_amr_spatial_runtime_does_not_carry_an_unexecuted_implicit_solve(): assert "resolve_implicit_components_compiled" not in source +def test_uniform_system_rejects_unpublished_newton_diagnostics_before_allocation(): + native = _function_body( + SYSTEM_INSTALL.read_text(encoding="utf-8"), + "void System::add_block(", + ) + python = PYTHON_SYSTEM_INSTALL.read_text(encoding="utf-8") + python_add_block = _python_function_source(python, "add_block") + python_add_equation = _python_function_source(python, "add_equation") + + assert "newton_diagnostics=true is unavailable" in native + assert "no typed implicit Program consumer publishes that report" in native + assert "diagnostics_.newton_reports[name]" not in native + for entrypoint in (python_add_block, python_add_equation): + assert entrypoint.index("_reject_unpublished_newton_diagnostics(time") < entrypoint.index( + "native_block_scalars(" + ) + + def test_amr_runtime_and_builders_do_not_decode_a_second_time_method(): for path in ( AMR_SYSTEM_HEADER, diff --git a/tests/python/architecture/test_route_registry_parity.py b/tests/python/architecture/test_route_registry_parity.py index 24e58158f..ca5ba851f 100644 --- a/tests/python/architecture/test_route_registry_parity.py +++ b/tests/python/architecture/test_route_registry_parity.py @@ -20,6 +20,7 @@ DISPATCH_API = ROOT / "include" / "pops" / "runtime" / "config" / "dispatch_tags.hpp" MODEL_API = ROOT / "include" / "pops" / "runtime" / "dynamic" / "model_registry.hpp" MODULE_CAPABILITIES = ROOT / "include" / "pops" / "runtime" / "module_capabilities.hpp" +SCHEME_DISPATCH = ROOT / "include" / "pops" / "runtime" / "builders" / "scheme_dispatch.hpp" def _load(path: Path, name: str): @@ -74,6 +75,31 @@ def test_native_capability_layout_parity_uses_the_generated_route_tokens(): assert '"uniform|amr"' not in source +def test_mc_and_superbee_use_the_generated_prepared_limiter_registry() -> None: + catalog = json.loads(CATALOG.read_text(encoding="utf-8")) + limiter = next(family for family in catalog["route_families"] + if family["name"] == "limiter") + rows = {row["token"]: row for row in limiter["routes"]} + dispatch = SCHEME_DISPATCH.read_text(encoding="utf-8") + capabilities = MODULE_CAPABILITIES.read_text(encoding="utf-8") + + for token, cpp_id, native_entry, cpp_type in ( + ("mc", "kMc", "pops::MC", "MC"), + ("superbee", "kSuperbee", "pops::Superbee", "Superbee"), + ): + row = rows[token] + assert row["cpp_id"] == cpp_id + assert row["native_entry"] == native_entry + assert row["metadata"] == { + "n_ghost": 2, "formal_order": 2, "muscl_compatible": True, + } + assert "X(%s, %s)" % (cpp_id, cpp_type) in dispatch + assert 'capability_route("limiter:%s", "available"' % token in capabilities + + assert 'capability_route("limiter:mc", "unavailable"' not in capabilities + assert 'capability_route("limiter:superbee", "unavailable"' not in capabilities + + def test_one_catalog_row_generates_both_language_surfaces(): generator = _load(GENERATOR, "_component_catalog_generator_contract") catalog = json.loads(CATALOG.read_text(encoding="utf-8")) diff --git a/tests/python/architecture/test_variable_recovery_consumer_cutover.py b/tests/python/architecture/test_variable_recovery_consumer_cutover.py new file mode 100644 index 000000000..8a25b75fd --- /dev/null +++ b/tests/python/architecture/test_variable_recovery_consumer_cutover.py @@ -0,0 +1,120 @@ +"""Architecture fence for the bounded ADC-755 runtime-consumer cutover.""" + +from pathlib import Path +import re + + +ROOT = Path(__file__).resolve().parents[3] +BLOCK_BUILDER = ROOT / "include/pops/runtime/builders/block/block_builder.hpp" +SYSTEM_FIELDS = ROOT / "src/runtime/system/system_fields.cpp" +FLUX_FAILURE = ROOT / "include/pops/numerics/fv/flux_failure.hpp" +FACE_FLUX = ROOT / "include/pops/numerics/spatial/primitives/face_flux.hpp" +RECOVERY = ROOT / "include/pops/numerics/nonlinear/prepared_variable_recovery.hpp" +SPATIAL_RECOVERY_CONSUMERS = ( + FACE_FLUX, + ROOT / "include/pops/numerics/spatial/operators/cartesian_operator.hpp", + ROOT / "include/pops/numerics/spatial/operators/masked_operator.hpp", + ROOT / "include/pops/numerics/spatial/operators/polar_operator.hpp", + ROOT / "include/pops/numerics/spatial/embedded_boundary/operator.hpp", +) + + +def _between(source: str, begin: str, end: str) -> str: + return source.split(begin, 1)[1].split(end, 1)[0] + + +def test_cell_primitive_conversion_has_one_prepared_fail_closed_authority(): + source = BLOCK_BUILDER.read_text(encoding="utf-8") + conversion = _between( + source, "make_cell_convert(const Model& m)", "\n}\n\n} // namespace pops" + ) + + assert "prepare_model_variable_recovery(m)" in conversion + assert "recover_prepared_variable(" in conversion + assert "outcome.publication_permitted()" in conversion + assert "return recovery_report(outcome)" in conversion + assert "m.to_primitive" not in conversion + + +def test_runtime_materialization_consumes_recovery_before_copying_candidate(): + source = SYSTEM_FIELDS.read_text(encoding="utf-8") + materialization = _between( + source, + "std::vector System::get_primitive_state", + "\nSolveReport System::solve_fields_in_place_", + ) + + recovery = materialization.index("const RecoveryReport recovery") + refusal = materialization.index("if (!recovery.publication_permitted())") + publication = materialization.index("prim[static_cast(c) * nn + k] = cell_out[c]") + assert recovery < refusal < publication + assert "variable recovery failed" in materialization + + +def test_type_erased_recovery_report_preserves_actual_method_identity(): + source = RECOVERY.read_text(encoding="utf-8") + report = _between(source, "struct RecoveryReport {", "\n};\n\nstatic_assert") + erasure = _between( + source, + "POPS_HD inline RecoveryReport recovery_report", + "\n}\n\ninline constexpr const char* recovery_status_name", + ) + + assert "RecoveryMethodKind selected_method_kind" in report + assert "RecoveryMethodKind last_method_kind" in report + assert "outcome.selected_method_kind" in erasure + assert "outcome.last_method_kind" in erasure + + +def test_runtime_recovery_failure_names_last_attempted_method(): + source = SYSTEM_FIELDS.read_text(encoding="utf-8") + materialization = _between( + source, + "std::vector System::get_primitive_state", + "\nSolveReport System::solve_fields_in_place_", + ) + + assert "recovery_method_kind_name(recovery.last_method_kind)" in materialization + assert "last_method_index=" in materialization + assert "std::to_string(recovery.last_method)" in materialization + + +def test_runtime_layer_has_no_independent_direct_primitive_recovery(): + runtime_sources = ( + *ROOT.glob("include/pops/runtime/**/*.hpp"), + *ROOT.glob("src/runtime/**/*.cpp"), + ) + bypasses = [] + for path in runtime_sources: + source = path.read_text(encoding="utf-8") + if re.search(r"\b(?:m|model)\.to_primitive\s*\(", source): + bypasses.append(path.relative_to(ROOT).as_posix()) + assert bypasses == [] + + +def test_face_reconstruction_returns_and_consumes_one_typed_recovery_report(): + reconstruction = FACE_FLUX.read_text(encoding="utf-8") + assert "struct ReconstructedFaceState" in reconstruction + assert "prepare_model_variable_recovery(model)" in reconstruction + assert "recover_prepared_variable(plan" in reconstruction + assert "recovery_report(outcome)" in reconstruction + assert "model.to_primitive" not in reconstruction + + failure_channel = FLUX_FAILURE.read_text(encoding="utf-8") + assert "record_recovery(const RecoveryReport& report" in failure_channel + assert "recovery_evaluation_status(report.status)" in failure_channel + + +def test_every_production_spatial_path_consumes_recovery_before_flux_evaluation(): + bypasses = [] + missing_consumers = [] + for path in SPATIAL_RECOVERY_CONSUMERS: + source = path.read_text(encoding="utf-8") + if re.search(r"\breconstruct_pp\s*<\s*Model\s*>\s*\(", source): + bypasses.append(path.relative_to(ROOT).as_posix()) + if "reconstruct_pp_recovered(" in source and ( + "record_reconstruction_recoveries(" not in source + ): + missing_consumers.append(path.relative_to(ROOT).as_posix()) + assert bypasses == [] + assert missing_consumers == [] diff --git a/tests/python/integration/amr/test_amr_weno5_hllc_roe.py b/tests/python/integration/amr/test_amr_weno5_hllc_roe.py index 58476f0b3..d4cc7b78c 100644 --- a/tests/python/integration/amr/test_amr_weno5_hllc_roe.py +++ b/tests/python/integration/amr/test_amr_weno5_hllc_roe.py @@ -3,7 +3,7 @@ DIVERGENCE CORRIGEE (audit GENERICITY_2026-06 §8 "registry des tags") : les branches hllc et roe du dispatch AMR (detail::dispatch_amr_block, amr_dsl_block.hpp) n'avaient PAS de -cas 'weno5' (seulement none/minmod/vanleer) alors que System::make_block (block_builder.hpp) le route. +cas 'weno5' (seulement les routes de halo <= 2) alors que System::make_block (block_builder.hpp) le route. Resultat : un utilisateur AmrSystem demandant un schema compressible weno5+hllc (ou weno5+roe) recevait "limiter inconnu 'weno5'" la ou le MEME modele buildait sous System. Les deux branches AMR portent desormais le cas weno5 (build_amr_block supporte deja Weno5, cable sur diff --git a/tests/python/integration/bindings/test_name_binding_runtime.py b/tests/python/integration/bindings/test_name_binding_runtime.py index 2e2b4e365..4052c9681 100644 --- a/tests/python/integration/bindings/test_name_binding_runtime.py +++ b/tests/python/integration/bindings/test_name_binding_runtime.py @@ -59,9 +59,7 @@ def passive_model(name): m = Model(name) (rho,) = m.conservative_vars("rho") a = 0.7 - u = m.primitive("u", a + 0.0 * rho) - v = m.primitive("v", a + 0.0 * rho) - m.primitive_vars(rho=rho, u=u, v=v) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[a * rho], y=[a * rho]) m.eigenvalues(x=[a + 0.0 * rho], y=[a + 0.0 * rho]) diff --git a/tests/python/integration/io/test_time_history_checkpoint.py b/tests/python/integration/io/test_time_history_checkpoint.py index 791a12d9d..73a56bb73 100644 --- a/tests/python/integration/io/test_time_history_checkpoint.py +++ b/tests/python/integration/io/test_time_history_checkpoint.py @@ -668,8 +668,7 @@ def _passive_source_model(name): m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/integration/native_loader/test_external_interface_backend.py b/tests/python/integration/native_loader/test_external_interface_backend.py index 07617eaa9..1e8a4d604 100644 --- a/tests/python/integration/native_loader/test_external_interface_backend.py +++ b/tests/python/integration/native_loader/test_external_interface_backend.py @@ -11,7 +11,7 @@ def test_all_required_native_families_are_generated_data_only_contracts(): expected = { - "numerical_flux", "ghost_boundary", "field_boundary_closure", "tagger", + "numerical_flux", "ghost_boundary", "boundary_flux", "field_boundary_closure", "tagger", "clustering", "transfer", "field_solver", "writer", "field_topology", } resolved = {name: interfaces.resolve(name) for name in expected} @@ -95,6 +95,7 @@ def test_boundary_handle_native_routes_are_generated_from_exact_interfaces(): "corner_resolver": ("ghost_boundary", "apply_region_batch"), "numerical_closure": ("ghost_boundary", "apply_region_batch"), "conservative_flux": ("numerical_flux", "evaluate_faces"), + "boundary_flux_provider": ("boundary_flux", "transform_faces"), "residual_operator": ("field_boundary_closure", "residual"), "linearization_operator": ("field_boundary_closure", "jvp"), } diff --git a/tests/python/integration/native_loader/test_normalized_program_execution.py b/tests/python/integration/native_loader/test_normalized_program_execution.py index d0e7ca6fc..2bcc69a70 100644 --- a/tests/python/integration/native_loader/test_normalized_program_execution.py +++ b/tests/python/integration/native_loader/test_normalized_program_execution.py @@ -53,8 +53,7 @@ def _require_native() -> None: def _authoring() -> tuple[Any, Any, Any, Any]: model = Model("normalized-execution-model") (rho,) = model.conservative_vars("rho") - velocity = model.primitive("u", 0.0 * rho) - model.primitive_vars(rho=rho, u=velocity) + model.primitive_vars(rho) model.conservative_from([rho]) model.flux(x=[0.0 * rho], y=[0.0 * rho]) model.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/integration/native_loader/test_prepared_krylov_method_component.py b/tests/python/integration/native_loader/test_prepared_krylov_method_component.py index 69f695d1a..ea4bd0a41 100644 --- a/tests/python/integration/native_loader/test_prepared_krylov_method_component.py +++ b/tests/python/integration/native_loader/test_prepared_krylov_method_component.py @@ -34,8 +34,7 @@ def _passive_model(name: str): model = Model(name) (rho,) = model.conservative_vars("rho") - velocity = model.primitive("u", 0.0 * rho) - model.primitive_vars(rho=rho, u=velocity) + model.primitive_vars(rho) model.conservative_from([rho]) model.flux(x=[0.0 * rho], y=[0.0 * rho]) model.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/integration/native_loader/test_prepared_nullspace_component.py b/tests/python/integration/native_loader/test_prepared_nullspace_component.py index b3cb24f68..1b65103c8 100644 --- a/tests/python/integration/native_loader/test_prepared_nullspace_component.py +++ b/tests/python/integration/native_loader/test_prepared_nullspace_component.py @@ -34,8 +34,7 @@ def _passive_model(name: str): model = Model(name) (rho,) = model.conservative_vars("rho") - velocity = model.primitive("u", 0.0 * rho) - model.primitive_vars(rho=rho, u=velocity) + model.primitive_vars(rho) model.conservative_from([rho]) model.flux(x=[0.0 * rho], y=[0.0 * rho]) model.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/integration/native_loader/test_prepared_preconditioner_component.py b/tests/python/integration/native_loader/test_prepared_preconditioner_component.py index 86e69580a..cd0b4c11e 100644 --- a/tests/python/integration/native_loader/test_prepared_preconditioner_component.py +++ b/tests/python/integration/native_loader/test_prepared_preconditioner_component.py @@ -34,8 +34,7 @@ def _passive_model(name: str): model = Model(name) (rho,) = model.conservative_vars("rho") - velocity = model.primitive("u", 0.0 * rho) - model.primitive_vars(rho=rho, u=velocity) + model.primitive_vars(rho) model.conservative_from([rho]) model.flux(x=[0.0 * rho], y=[0.0 * rho]) model.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/integration/runtime/test_axial_slip_wall_pipeline.py b/tests/python/integration/runtime/test_axial_slip_wall_pipeline.py new file mode 100644 index 000000000..39e665402 --- /dev/null +++ b/tests/python/integration/runtime/test_axial_slip_wall_pipeline.py @@ -0,0 +1,108 @@ +"""An axial component survives the public compile-to-bind boundary pipeline.""" + +from __future__ import annotations + +import numpy as np +import pops +import pops.lib.time as libtime +import pytest +from pops.boundary import TransportBoundarySet +from pops.boundary.transport import SlipWall +from pops.domain import Rectangle +from pops.frames import Cartesian2D, Z_AXIS +from pops.layouts import Uniform +from pops.math import ddt, div +from pops.mesh import CartesianGrid +from pops.numerics import DiscretizationPlan, FiniteVolume, reconstruction, riemann, variables +from pops.physics import Axial, Density, Momentum +from pops.representations import Conservative +from pops.spaces import CellState +from pops.time import FixedDt +from tests.python.support.native_execution_context import artifact_execution_context + + +pytestmark = [pytest.mark.compiler, pytest.mark.native_loader] + + +def _axial_wall_case() -> tuple[pops.Case, Uniform]: + frame = Rectangle( + "axial-wall-square", lower=(0.0, 0.0), upper=(1.0, 1.0) + ).frame(Cartesian2D()) + x_axis, y_axis = frame.axes + model = pops.Model("axial-wall-model", frame=frame) + state = model.state( + "U", + components=("rho", "mx", "my", "bz"), + representation=Conservative(), + space=CellState(frame=frame), + roles={ + "rho": Density(), + "mx": Momentum(axis=x_axis), + "my": Momentum(axis=y_axis), + "bz": Axial(axis=Z_AXIS), + }, + ) + rho, mx, my, bz = state + flux = model.flux( + "identity-flux", + frame=frame, + state=state, + components={ + x_axis: (rho, mx, my, bz), + y_axis: (rho, mx, my, bz), + }, + waves={ + x_axis: (1.0, 1.0, 1.0, 1.0), + y_axis: (1.0, 1.0, 1.0, 1.0), + }, + ) + rate = model.rate("transport", equation=ddt(state) == -div(flux)) + numerics = DiscretizationPlan() + numerics.rates.add( + rate, + FiniteVolume( + flux=flux, + variables=variables.Conservative(state), + reconstruction=reconstruction.FirstOrder(), + riemann=riemann.Rusanov(), + ), + ) + + case = pops.Case("axial-slip-wall-pipeline") + block = case.block("fluid", model=model) + numerics.boundaries.add( + TransportBoundarySet( + { + boundary: SlipWall(state=block[state]) + for boundary in frame.boundaries.all + } + ) + ) + case.numerics(numerics, block=block) + program = libtime.ForwardEuler(block[state], rate=rate) + program.step_strategy(FixedDt(0.01)) + case.program(program) + return case, Uniform(CartesianGrid(frame=frame, cells=(4, 4))) + + +def test_axial_role_compiles_binds_and_round_trips_native_metadata( + isolated_native_cache, native_cxx, kokkos_root, +) -> None: + del isolated_native_cache, native_cxx, kokkos_root + case, layout = _axial_wall_case() + artifact = pops.compile(pops.resolve(pops.validate(case), layout=layout)) + initial = np.ones((4, 4, 4), dtype=np.float64) + runtime = pops.bind( + artifact, + initial_state={"fluid": initial}, + resources={"execution_context": artifact_execution_context(artifact)}, + ) + + assert list(runtime._executor._s.variable_roles("fluid", "conservative")) == [ + "density", + "momentum_x", + "momentum_y", + "axial_z", + ] + installed = runtime._executor._boundary_authorities["fluid"] + assert [face["type"] for face in installed["faces"]] == ["slip_wall"] * 4 diff --git a/tests/python/test_durations.json b/tests/python/test_durations.json index 4af80edb6..49870c31d 100644 --- a/tests/python/test_durations.json +++ b/tests/python/test_durations.json @@ -60,6 +60,7 @@ "tests/python/integration/native_loader/test_prepared_preconditioner_component.py": 120.0, "tests/python/integration/native_loader/test_ssprk3_production.py": 155.7, "tests/python/integration/native_loader/test_uniform_restart_missing_history.py": 30.0, + "tests/python/integration/runtime/test_axial_slip_wall_pipeline.py": 120.0, "tests/python/integration/runtime/test_coupling_preset_parity.py": 0.5, "tests/python/integration/runtime/test_diocotron_analytic_initial.py": 120.0, "tests/python/integration/runtime/test_dsl_runtime_params.py": 300.0, @@ -173,6 +174,8 @@ "tests/python/unit/codegen/test_program_emit_params_multimodel.py": 2.0, "tests/python/unit/codegen/test_program_graph_lowering.py": 2.0, "tests/python/unit/codegen/test_program_model_graph.py": 2.0, + "tests/python/unit/codegen/test_recovery_admissibility_codegen.py": 2.0, + "tests/python/unit/codegen/test_representation_arity.py": 2.0, "tests/python/unit/codegen/test_rhs_jacvec_boundary_emit.py": 2.0, "tests/python/unit/codegen/test_schedule_extension_protocol.py": 2.0, "tests/python/unit/codegen/test_scheduler_codegen.py": 0.5, @@ -408,7 +411,7 @@ "unit_seconds": "per-file pytest wall time", "measured_source": "borrowed _pops.so locally plus GitHub Actions run 30190778708 per-test timings", "estimated_note": "Unmeasured files use conservative path/content tiers (1/2/5/30/60/120 s); compiler-gated files retain native-compile estimates. Refresh every estimated row from a full CI run gate-python timing artifact.", - "estimated_count": 223, + "estimated_count": 226, "estimated_files": [ "tests/python/examples/final/test_hyqmom15_final_example.py", "tests/python/examples/final/test_scalar_advection_final_example.py", @@ -447,6 +450,7 @@ "tests/python/integration/native_loader/test_prepared_preconditioner_component.py", "tests/python/integration/native_loader/test_ssprk3_production.py", "tests/python/integration/native_loader/test_uniform_restart_missing_history.py", + "tests/python/integration/runtime/test_axial_slip_wall_pipeline.py", "tests/python/integration/runtime/test_diocotron_analytic_initial.py", "tests/python/integration/runtime/test_dsl_runtime_params.py", "tests/python/integration/runtime/test_final_condensed_uniform_runtime.py", @@ -513,6 +517,8 @@ "tests/python/unit/codegen/test_program_emit_params_multimodel.py", "tests/python/unit/codegen/test_program_graph_lowering.py", "tests/python/unit/codegen/test_program_model_graph.py", + "tests/python/unit/codegen/test_recovery_admissibility_codegen.py", + "tests/python/unit/codegen/test_representation_arity.py", "tests/python/unit/codegen/test_rhs_jacvec_boundary_emit.py", "tests/python/unit/codegen/test_schedule_extension_protocol.py", "tests/python/unit/codegen/test_shared_interface_validation.py", @@ -634,6 +640,6 @@ "tests/python/unit/time/test_typed_provenance_guards.py", "tests/python/unit/time/test_typed_schedule.py" ], - "total_files": 404 + "total_files": 407 } } diff --git a/tests/python/unit/analytic/test_analytic_expressions.py b/tests/python/unit/analytic/test_analytic_expressions.py index 9d32feb1b..d745b438b 100644 --- a/tests/python/unit/analytic/test_analytic_expressions.py +++ b/tests/python/unit/analytic/test_analytic_expressions.py @@ -11,6 +11,7 @@ import pytest +import pops from pops.analytic import ( AnalyticTruthValueError, PredicateExpr, @@ -34,6 +35,7 @@ radius, sin, sqrt, + time, where, x, y, @@ -69,6 +71,30 @@ def test_coordinates_are_typed_and_bound_to_one_frame() -> None: x_value + x(_frame("other")) +def test_physical_time_is_bound_to_one_exact_owner_qualified_clock() -> None: + program = pops.Program("analytic-time-program") + value = time(program.clock) + payload = value.to_data() + + assert value.time_clocks() == (program.clock,) + assert payload["root"] == { + "kind": "scalar", + "op": "time", + "clock": program.clock.to_data(), + "clock_id": program.clock.qualified_id, + } + assert ScalarExpr.from_data(payload).same_as(value) + + from pops.time import Clock + + with pytest.raises(TypeError, match="owner-qualified"): + time(Clock("unowned")) + forged = copy.deepcopy(payload) + forged["root"]["clock_id"] = "pops.clock.v1::sha256:forged" + with pytest.raises(ValueError, match="Clock identity"): + ScalarExpr.from_data(forged) + + def test_scalar_math_builds_a_data_only_canonical_tree() -> None: frame = _frame() x_value, y_value = coordinates(frame) diff --git a/tests/python/unit/boundary/test_transport_authoring.py b/tests/python/unit/boundary/test_transport_authoring.py index 6ec105491..03966c437 100644 --- a/tests/python/unit/boundary/test_transport_authoring.py +++ b/tests/python/unit/boundary/test_transport_authoring.py @@ -1,19 +1,22 @@ from __future__ import annotations +from dataclasses import replace + import pytest import pops -from pops.boundary import TransportBoundarySet +from pops.boundary import TransportBoundarySet, model_primitive_to_conservative from pops.boundary.transport import ResolvedTransportBoundarySet -from pops.boundary.transport import Inflow, Outflow +from pops.boundary.transport import Inflow, Outflow, SlipWall from pops.domain import Rectangle -from pops.frames import Cartesian2D +from pops.frames import Cartesian2D, Z_AXIS from pops.math import ddt, div from pops.numerics import DiscretizationPlan, reconstruction, riemann, variables from pops.numerics.reconstruction import limiters from pops.numerics.spatial import FiniteVolume from pops.params import RuntimeParam -from pops.representations import Conservative +from pops.physics import Axial, Density, Momentum +from pops.representations import Conservative, Primitive from pops.spaces import CellState @@ -110,6 +113,213 @@ def test_transport_set_resolves_exact_ports_values_and_derived_stencil_requireme } +def test_primitive_fixed_state_lowers_only_through_the_exact_block_model_converter(): + frame, _, _, _, numerics, case, block, block_state = _authoring() + converter = model_primitive_to_conservative(block_state) + numerics.boundaries.add(TransportBoundarySet({ + frame.boundaries.x_min: Inflow( + state=block_state, + value=0.25, + representation=Primitive(), + converter=converter, + ), + frame.boundaries.x_max: Outflow(state=block_state), + frame.boundaries.y_min: Inflow(state=block_state, value=0.25), + frame.boundaries.y_max: Outflow(state=block_state), + })) + case.numerics(numerics, block=block) + case.validate_report().raise_if_error() + + authority = case._resolved_numerics_for("tracer").boundaries[0] + compiled = authority.compile_boundary_data() + runtime = authority.runtime_boundary_data({}) + compiled_xmin = next(face for face in compiled["faces"] if face["ordinal"] == 0) + runtime_xmin = next(face for face in runtime["faces"] if face["ordinal"] == 0) + expected_state = authority.conditions[0].state + expected = model_primitive_to_conservative(expected_state).qualified_id + assert compiled_xmin["representation"] == "primitive" + assert compiled_xmin["converter"] == expected + assert runtime_xmin["representation"] == "primitive" + assert runtime_xmin["converter"] == expected + + from pops.mesh.boundaries.compiled_plan import CompiledBoundaryPlan + + detached_compile = dict(compiled) + detached_compile.update({ + "ghost_plan_identity": authority.plan.canonical_id, + "producer_order": [], + "component_region_templates": [], + }) + detached_xmin = next( + face for face in CompiledBoundaryPlan(detached_compile).runtime_boundary_data({})["faces"] + if face["ordinal"] == 0 + ) + assert detached_xmin["representation"] == "primitive" + assert detached_xmin["converter"] == expected + + from pops.model import Handle + + converted_condition = next( + row for row in authority.conditions if row.geometry.axis.index == 0 + and row.geometry.side.value == "lower" + ) + forged_flow = replace( + converted_condition.provider.dependencies.representation, + converter=Handle( + "forged-converter", + kind="representation_conversion", + owner=converted_condition.state.owner_path, + ), + ) + forged_dependencies = replace( + converted_condition.provider.dependencies, + representation=forged_flow, + ) + forged_condition = replace( + converted_condition, + provider=replace(converted_condition.provider, dependencies=forged_dependencies), + ) + with pytest.raises(NotImplementedError, match="exact model_primitive_to_conservative"): + replace( + authority, + conditions=tuple( + forged_condition if row is converted_condition else row + for row in authority.conditions + ), + ) + + +def test_analytic_inflow_lowers_typed_x_time_and_bound_parameters_without_callback(): + from pops.analytic import param, time, x + from pops.mesh.boundaries.compiled_plan import CompiledBoundaryPlan + from pops.model import BindSchema + + frame, _, inlet, _, numerics, case, block, block_state = _authoring() + program = pops.Program("analytic-boundary-clock") + analytic_value = 1.0 + x(frame) + time(program.clock) + param(inlet) + numerics.boundaries.add( + TransportBoundarySet( + { + frame.boundaries.x_min: Inflow(state=block_state, value=analytic_value), + frame.boundaries.x_max: Outflow(state=block_state), + frame.boundaries.y_min: Inflow(state=block_state, value=0.25), + frame.boundaries.y_max: Outflow(state=block_state), + } + ) + ) + case.numerics(numerics, block=block) + authority = case._resolved_numerics_for("tracer").boundaries[0] + + analytic_condition = next( + row + for row in authority.conditions + if row.geometry.axis.index == 0 and row.geometry.side.value == "lower" + ) + assert analytic_condition.provider.dependencies.states == () + assert len(analytic_condition.provider.dependencies.time) == 1 + assert len(analytic_condition.provider.dependencies.runtime_params) == 1 + assert analytic_condition.values[0].frame_id == frame.canonical_id + + schema = BindSchema.from_problem(case) + bindings = schema.resolve_bind({}, compile_values=schema.resolve_compile()) + runtime = authority.runtime_boundary_data(bindings) + xlo = next(face for face in runtime["faces"] if face["ordinal"] == 0) + assert xlo["values"] == [0.0] + assert xlo["analytic_clock"] == program.clock.qualified_id + assert xlo["analytic_programs"][0]["opcodes"] == [ + "constant", + "x", + "add", + "input", + "add", + "constant", + "add", + ] + assert xlo["analytic_programs"][0]["literals"][3] == 0.0 + assert xlo["analytic_programs"][0]["literals"][5] == 0.25 + + compiled = authority.compile_boundary_data() + compiled.update( + { + "ghost_plan_identity": authority.plan.canonical_id, + "producer_order": [], + "component_region_templates": [], + } + ) + detached = CompiledBoundaryPlan(compiled).runtime_boundary_data(bindings) + assert detached["faces"] == runtime["faces"] + + +def test_analytic_inflow_fails_closed_for_primitive_per_point_conversion(): + from pops.analytic import x + + frame, _, _, _, numerics, case, block, block_state = _authoring() + numerics.boundaries.add( + TransportBoundarySet( + { + frame.boundaries.x_min: Inflow( + state=block_state, + value=x(frame), + representation=Primitive(), + converter=model_primitive_to_conservative(block_state), + ), + frame.boundaries.x_max: Outflow(state=block_state), + frame.boundaries.y_min: Inflow(state=block_state, value=0.25), + frame.boundaries.y_max: Outflow(state=block_state), + } + ) + ) + case.numerics(numerics, block=block) + + with pytest.raises(NotImplementedError, match="analytic primitive inflow"): + case._resolved_numerics_for("tracer") + + +def test_analytic_inflow_fails_closed_for_discrete_setup_inputs(): + from pops.analytic import input + + frame, _, _, _, numerics, case, block, block_state = _authoring() + numerics.boundaries.add( + TransportBoundarySet( + { + frame.boundaries.x_min: Inflow( + state=block_state, value=input(0, "n")), + frame.boundaries.x_max: Outflow(state=block_state), + frame.boundaries.y_min: Inflow(state=block_state, value=0.25), + frame.boundaries.y_max: Outflow(state=block_state), + } + ) + ) + case.numerics(numerics, block=block) + + with pytest.raises(NotImplementedError, match="setup-program discrete inputs"): + case._resolved_numerics_for("tracer") + + +def test_analytic_inflow_fails_closed_when_one_plan_mixes_logical_clocks(): + from pops.analytic import time + + frame, _, _, _, numerics, case, block, block_state = _authoring() + first = pops.Program("analytic-boundary-first-clock") + second = pops.Program("analytic-boundary-second-clock") + numerics.boundaries.add( + TransportBoundarySet( + { + frame.boundaries.x_min: Inflow( + state=block_state, value=time(first.clock)), + frame.boundaries.x_max: Outflow(state=block_state), + frame.boundaries.y_min: Inflow( + state=block_state, value=time(second.clock)), + frame.boundaries.y_max: Outflow(state=block_state), + } + ) + ) + case.numerics(numerics, block=block) + + with pytest.raises(ValueError, match="plan cannot mix several logical Clocks"): + case._resolved_numerics_for("tracer") + + def test_transport_set_rejects_incomplete_geometry_at_resolution(): frame, _, _, inlet_value, numerics, case, block, block_state = _authoring() numerics.boundaries.add(TransportBoundarySet({ @@ -135,3 +345,164 @@ def test_transport_conditions_require_instance_handles_and_exact_component_cover case.numerics(numerics, block=block) with pytest.raises(ValueError, match="prescribe exactly 1 components, got 2"): case._resolved_numerics_for("tracer") + + +def test_directional_characteristic_provider_cannot_fall_back_to_native_inflow(): + from pops.mesh.boundaries import ( + CharacteristicClosure, + ClosureMode, + DirectionalTransport, + IncomingMultiplicity, + SignDependence, + SonicPolicy, + ) + + class CharacteristicInflow: + def __init__(self, base): + self.base = base + self.state = base.state + + def inspect(self): + return {**self.base.inspect(), "characteristic": "directional"} + + def resolve_references(self, resolver): + return type(self)(self.base.resolve_references(resolver)) + + def resolve_condition(self, **kwargs): + resolved = self.base.resolve_condition(**kwargs) + dependencies = replace( + resolved.provider.dependencies, + characteristic=CharacteristicClosure( + mode=ClosureMode.DIRECTIONAL, + sign_dependence=SignDependence.FIXED, + sonic=SonicPolicy.NEUTRAL, + incoming=IncomingMultiplicity.SINGLE, + characteristics=(resolved.state,), + ), + ) + return replace( + resolved, + provider=DirectionalTransport( + handle=resolved.provider.handle, + outputs=resolved.provider.outputs, + dependencies=dependencies, + ), + ) + + frame, _, _, inlet_value, numerics, case, block, block_state = _authoring() + numerics.boundaries.add(TransportBoundarySet({ + frame.boundaries.x_min: CharacteristicInflow( + Inflow(state=block_state, value=inlet_value) + ), + frame.boundaries.x_max: Outflow(state=block_state), + frame.boundaries.y_min: Inflow(state=block_state, value=inlet_value), + frame.boundaries.y_max: Outflow(state=block_state), + })) + case.numerics(numerics, block=block) + + with pytest.raises( + NotImplementedError, + match="prepared model eigenstructure.*cannot fall back", + ): + case._resolved_numerics_for("tracer") + + +def test_resolved_transport_condition_rejects_a_forged_provider_law(): + from pops.mesh.boundaries import BoundaryProviderKind + + frame, _, _, inlet_value, numerics, case, block, block_state = _authoring() + numerics.boundaries.add(TransportBoundarySet({ + frame.boundaries.x_min: Inflow(state=block_state, value=inlet_value), + frame.boundaries.x_max: Outflow(state=block_state), + frame.boundaries.y_min: Inflow(state=block_state, value=inlet_value), + frame.boundaries.y_max: Outflow(state=block_state), + })) + case.numerics(numerics, block=block) + authority = case._resolved_numerics_for("tracer").boundaries[0] + condition = next( + row for row in authority.conditions if row.condition_type == "inflow") + forged = replace(condition.provider, kind=BoundaryProviderKind.OUTFLOW) + + with pytest.raises(ValueError, match="condition 'inflow'.*provider law 'outflow'"): + replace(condition, provider=forged) + + +def test_slip_wall_requires_roles_and_lowers_one_model_aware_face_law(): + frame, _, _, _, numerics, case, block, block_state = _authoring() + numerics.boundaries.add(TransportBoundarySet({ + frame.boundaries.x_min: Outflow(state=block_state), + frame.boundaries.x_max: Outflow(state=block_state), + frame.boundaries.y_min: SlipWall(state=block_state), + frame.boundaries.y_max: Outflow(state=block_state), + })) + case.numerics(numerics, block=block) + with pytest.raises(ValueError, match="declared normal polar-vector component"): + case._resolved_numerics_for("tracer") + + domain = Rectangle("fluid_unit", (0.0, 0.0), (1.0, 1.0)) + fluid_frame = domain.frame(Cartesian2D()) + x_axis, y_axis = fluid_frame.axes + model = pops.Model("wall_model", frame=fluid_frame) + state = model.state( + "U", + components=("rho", "mx", "my", "bz"), + representation=Conservative(), + space=CellState(frame=fluid_frame), + roles={ + "rho": Density(), + "mx": Momentum(axis=x_axis), + "my": Momentum(axis=y_axis), + "bz": Axial(axis=Z_AXIS), + }, + ) + rho, mx, my, bz = state + flux = model.flux( + "flux", + frame=fluid_frame, + state=state, + components={ + x_axis: (rho, mx, my, bz), + y_axis: (rho, mx, my, bz), + }, + waves={ + x_axis: (1.0, 1.0, 1.0, 1.0), + y_axis: (1.0, 1.0, 1.0, 1.0), + }, + ) + rate = model.rate("rate", equation=ddt(state) == -div(flux)) + method = FiniteVolume( + flux=flux, + variables=variables.Conservative(state), + reconstruction=reconstruction.FirstOrder(), + riemann=riemann.Rusanov(), + ) + plan = DiscretizationPlan() + plan.rates.add(rate, method) + wall_case = pops.Case("wall_case") + wall_block = wall_case.block("fluid", model=model) + wall_state = wall_block[state] + plan.boundaries.add(TransportBoundarySet({ + boundary: SlipWall(state=wall_state) + for boundary in fluid_frame.boundaries.all + })) + wall_case.numerics(plan, block=wall_block) + + authority = wall_case._resolved_numerics_for("fluid").boundaries[0] + assert {row.condition_type for row in authority.conditions} == {"slip_wall"} + runtime = authority.runtime_boundary_data({}) + assert [row["type"] for row in runtime["faces"]] == ["slip_wall"] * 4 + assert all(row["values"] == [0.0] * 4 for row in runtime["faces"]) + + from pops.mesh.boundaries.compiled_plan import CompiledBoundaryPlan + + detached_compile_data = authority.compile_boundary_data() + detached_compile_data.update( + { + "ghost_plan_identity": authority.plan.canonical_id, + "producer_order": [], + "component_region_templates": [], + } + ) + detached_runtime = CompiledBoundaryPlan(detached_compile_data).runtime_boundary_data({}) + assert detached_runtime["faces"] == runtime["faces"] + assert detached_runtime["required_depth"] == runtime["required_depth"] diff --git a/tests/python/unit/codegen/test_compiled_model_boundary.py b/tests/python/unit/codegen/test_compiled_model_boundary.py index 18c24cbe6..c58666ce4 100644 --- a/tests/python/unit/codegen/test_compiled_model_boundary.py +++ b/tests/python/unit/codegen/test_compiled_model_boundary.py @@ -32,6 +32,9 @@ def _model_hash(self): def check(self): return None + def __pops_bind_component_provider_packs__(self, packs): + self.provider_packs = packs + def __pops_native_loader_source__( self, *, name=None, target="system", hoist_reciprocals=False): return "// compiled-model-boundary fixture\n" diff --git a/tests/python/unit/codegen/test_compiler_model_provider.py b/tests/python/unit/codegen/test_compiler_model_provider.py index 7d0079d03..c915bb37c 100644 --- a/tests/python/unit/codegen/test_compiler_model_provider.py +++ b/tests/python/unit/codegen/test_compiler_model_provider.py @@ -10,6 +10,7 @@ from pops.codegen.module_lowering import lower_and_validate from pops._ir.expr import Const from pops.model import Module, Rate +from pops.model.provider_pack import MissingInputProvider from pops.physics._facade import Model @@ -27,6 +28,21 @@ def _facade_model(name: str = "provider") -> Model: return model +def _field_dependent_flux_model(name: str, *, with_provider: bool = True) -> Model: + model = Model(name) + (rho,) = model.conservative_vars("rho") + phi = model.aux("phi") + grad_x = model.aux("grad_x") + model.aux("grad_y") + model.primitive_vars(rho=rho) + model.conservative_from([rho]) + model.flux(x=[rho * grad_x], y=[rho * grad_x]) + model.eigenvalues(x=(Const(1.0),), y=(Const(1.0),)) + if with_provider: + model.elliptic_rhs(rho + Const(0.0) * phi) + return model + + class _ThirdPartyProvider: """An external provider delegates only the documented compiler contract.""" @@ -50,6 +66,9 @@ class _CheckEmitter: def check(self) -> None: return None + def __pops_bind_component_provider_packs__(self, packs) -> None: + self.provider_packs = packs + def __pops_native_loader_source__( self, *, name=None, target="system", hoist_reciprocals=False): return "// test native loader\n" @@ -97,6 +116,48 @@ def test_frozen_module_remains_the_canonical_compiler_ir(): assert lowering.source_module is module +def test_facade_and_formula_carrier_share_one_minimal_flux_provider_pack(): + model = _field_dependent_flux_model("facade-flux-pack") + + emitted, source_module = lower_and_validate(model, facade=model) + + assert emitted is model + rows = model._component_flux_provider_metadata["entries"] + assert rows == model._m._component_flux_provider_metadata["entries"] + assert [row["key"]["component"] for row in rows] == ["grad_x"] + assert rows[0]["provider"]["availability"] is True + assert rows[0]["key"]["owner_qid"] == str(source_module.owner_path.canonical()) + + source = model.__pops_native_loader_source__() + assert rows[0]["key"]["owner_qid"] in source + assert '"grad_x"' in source + assert "true, 1" in source + assert "static constexpr int n_flux_providers = 1;" in source + assert "flux_provider_requirements" in source + + +def test_field_dependent_flux_without_provider_fails_before_native_source(): + model = _field_dependent_flux_model("missing-flux-provider", with_provider=False) + + with pytest.raises(MissingInputProvider, match="unset"): + lower_and_validate(model, facade=model) + + +def test_same_field_spelling_under_distinct_model_owners_stays_distinct_in_emitted_pack(): + left = _field_dependent_flux_model("left-flux-owner") + right = _field_dependent_flux_model("right-flux-owner") + + lower_and_validate(left, facade=left) + lower_and_validate(right, facade=right) + left_owner = left._component_flux_provider_metadata["entries"][0]["key"]["owner_qid"] + right_owner = right._component_flux_provider_metadata["entries"][0]["key"]["owner_qid"] + + assert left_owner != right_owner + assert left_owner in left.__pops_native_loader_source__() + assert right_owner not in left.__pops_native_loader_source__() + assert right_owner in right.__pops_native_loader_source__() + + class _MissingProtocol: pass diff --git a/tests/python/unit/codegen/test_component_provider_pack.py b/tests/python/unit/codegen/test_component_provider_pack.py index 170d0ca98..6a4b12f76 100644 --- a/tests/python/unit/codegen/test_component_provider_pack.py +++ b/tests/python/unit/codegen/test_component_provider_pack.py @@ -64,6 +64,29 @@ def test_minimal_selection_preserves_qualified_identity_and_refuses_missing_prov pack.select([ComponentKey("case/missing", "field", "electric", "grad_x")]) +def test_component_selection_is_space_qualified_and_refuses_homonym_ambiguity(): + contract = ComponentContract("field", "cell", "V/m", "cell") + left = ComponentKey("owner", "field", "left", "grad_x") + right = ComponentKey("owner", "field", "right", "grad_x") + pack = ProviderPack([ + (left, contract, ProviderEntry("left_solver", True, 0)), + (right, contract, ProviderEntry("right_solver", True, 0)), + ]) + + selected = pack.select_components( + owner_qid="owner", + spaces=(("field", "left"),), + components=("grad_x",), + ) + assert tuple(selected) == (left,) + with pytest.raises(MissingInputProvider, match="ambiguous component"): + pack.select_components( + owner_qid="owner", + spaces=(("field", "left"), ("field", "right")), + components=("grad_x",), + ) + + def test_operator_provider_pack_contains_fields_but_not_explicit_state_trace(): module = Module("operator_pack") state = module.state_space("U", ("rho",)) @@ -78,6 +101,21 @@ def test_operator_provider_pack_contains_fields_but_not_explicit_state_trace(): } +def test_operator_requirements_select_only_declared_components(): + module = Module("operator_component_pack") + state = module.state_space("U", ("rho",)) + fields = module.field_space("electric", ("phi", "grad_x", "grad_y")) + module.operator("solve", state >> fields, "field_operator", expr=1.0) + operator = SimpleNamespace( + signature=SimpleNamespace(inputs=(state, fields)), + requirements={"aux": ("grad_x",)}, + ) + + pack = build_operator_provider_pack(module, operator) + + assert tuple(key.component for key in pack) == ("grad_x",) + + def test_provider_pack_accepts_exact_capacity_and_refuses_capacity_plus_one_atomically(): first = _row("rho", 0) second = _row("mx", 1) diff --git a/tests/python/unit/codegen/test_fail_closed_reports.py b/tests/python/unit/codegen/test_fail_closed_reports.py index 8d291a83d..6773f344d 100644 --- a/tests/python/unit/codegen/test_fail_closed_reports.py +++ b/tests/python/unit/codegen/test_fail_closed_reports.py @@ -70,6 +70,15 @@ def test_mpi_world_route_reports_only_proved_native_availability(supports_mpi, e assert weno.layout == "uniform|amr" assert "ratio-2 2D AMR" in weno.limitation assert "order-5" in weno.limitation + for feature in ("limiter:mc", "limiter:superbee"): + limiter = routes[feature] + assert limiter.status == "available" + assert limiter.backend == "production" + assert limiter.layout == "uniform|amr" + assert "formal_order=2" in limiter.limitation + assert "ghost_depth=2" in limiter.limitation + assert limiter.available_route == "" + assert limiter.alternative == "" amr_implicit = routes["amr:source_implicit_program"] assert amr_implicit.status == "unavailable" assert "no temporal fallback" in amr_implicit.limitation @@ -84,6 +93,132 @@ def test_mpi_world_route_reports_only_proved_native_availability(supports_mpi, e ) +def test_transport_boundary_routes_report_exact_supported_envelope_and_missing_kernels(): + report = capability_reports.native_capability_report( + flags={"supports_mpi": True, "supports_gpu": False, "supports_amr": True}, + source="test-manifest", + ) + routes = {row.feature: row for row in report.routes} + + prepared = routes["boundary:prepared_transport"] + assert prepared.status == "partial" + assert prepared.layout == "uniform|amr" + assert prepared.backend == "production" + assert prepared.mpi is True + assert prepared.gpu is False + assert "one prepared 2D model-aware plan" in prepared.limitation + assert "typed-role slip wall" in prepared.limitation + assert "model primitive-to-conservative" in prepared.limitation + assert "coarse-fine ghosts under the prepared transfer authority" in prepared.limitation + assert "corners explicitly not required" in prepared.limitation + + conversion = routes["boundary:representation_conversion"] + assert conversion.status == "partial" + assert conversion.layout == "uniform|amr" + assert conversion.backend == "production" + assert "to_conservative provider" in conversion.limitation + assert "recovery" in conversion.limitation + + analytic = routes["boundary:analytic_xtp"] + assert analytic.status == "partial" + assert analytic.layout == "uniform|amr" + assert analytic.backend == "production" + assert "analytic ScalarExpr" in analytic.limitation + assert "exact logical Clock" in analytic.limitation + assert "state/field/input reads remain unavailable" in analytic.limitation + assert "axis-permuted periodic coordinates" in analytic.limitation + + expected_unavailable = { + "boundary:characteristic_no_inflow": ( + "executable model eigenstructure", + "prepared characteristic kernel", + ), + } + for feature, (limitation, alternative) in expected_unavailable.items(): + route = routes[feature] + assert route.status == "unavailable" + assert route.layout == "uniform|amr" + assert limitation in route.limitation + assert alternative in route.alternative + assert route.error_message + post_riemann = routes["boundary:post_riemann_flux"] + assert post_riemann.status == "partial" + assert post_riemann.layout == "uniform|amr" + assert post_riemann.backend == "production" + assert "outward-normal face flux" in post_riemann.limitation + assert "Riemann solve and divergence/reflux" in post_riemann.limitation + assert "2D Cartesian host-batch" in post_riemann.limitation + gpu_report = capability_reports.native_capability_report( + flags={"supports_mpi": True, "supports_gpu": True, "supports_amr": True}, + source="test-gpu-manifest", + ) + gpu_post_riemann = { + row.feature: row for row in gpu_report.routes + }["boundary:post_riemann_flux"] + assert gpu_post_riemann.gpu is False + + +def test_riemann_recovery_routes_distinguish_typed_rejection_from_missing_policy(): + report = capability_reports.native_capability_report( + flags={"supports_mpi": True, "supports_gpu": False, "supports_amr": True}, + source="test-manifest", + ) + routes = {row.feature: row for row in report.routes} + + typed = routes["riemann:typed_failure_outcome"] + assert typed.status == "partial" + assert typed.layout == "uniform|amr" + assert typed.backend == "production" + assert typed.mpi is True + assert typed.gpu is False + assert "one device-copyable FluxEvaluation" in typed.limitation + assert "typed status, stability bound, and reason code" in typed.limitation + assert "reject the owning transaction" in typed.limitation + assert "no fallback solver can be selected" in typed.limitation + + policy = routes["riemann:prepared_recovery_policy"] + assert policy.status == "unavailable" + assert policy.layout == "uniform|amr" + assert policy.backend == "none" + assert "no prepared ordered Riemann recovery chain" in policy.limitation + assert "requested-versus-used solver outcome" in policy.limitation + assert "never substitutes another solver" in policy.limitation + assert "typed rejection and transactional rollback" in policy.available_route + assert "consume rejection through the step retry/failure policy" in policy.alternative + assert policy.error_message + + +def test_variable_recovery_routes_separate_delivered_consumers_from_complete_cutover(): + report = capability_reports.native_capability_report( + flags={"supports_mpi": True, "supports_gpu": False, "supports_amr": True}, + source="test-manifest", + ) + routes = {row.feature: row for row in report.routes} + + prepared = routes["recovery:prepared_variable"] + assert prepared.status == "partial" + assert prepared.layout == "uniform|amr" + assert prepared.backend == "production" + assert prepared.mpi is True + assert prepared.gpu is False + assert "one block-prepared closed-form method" in prepared.limitation + assert "device-copyable RecoveryOutcome/RecoveryReport" in prepared.limitation + assert "selected and last-attempted method kinds" in prepared.limitation + assert "consume publication permission" in prepared.limitation + assert "no implicit repair, fallback, or mutable cache" in prepared.limitation + + cutover = routes["recovery:complete_consumer_cutover"] + assert cutover.status == "unavailable" + assert cutover.layout == "uniform|amr" + assert cutover.backend == "none" + assert "initial and analytic materialization" in cutover.limitation + assert "AMR transfer/regrid" in cutover.limitation + assert "persistent warm starts" in cutover.limitation + assert "System materialization and spatial face reconstruction" in cutover.available_route + assert "missing fallible provider and cache/restart contracts" in cutover.alternative + assert cutover.error_message + + def test_defaults_source_only_is_not_used_for_a_loaded_broken_extension(monkeypatch): monkeypatch.setattr(defaults, "_native_extension", lambda: None) assert defaults.numerical_defaults_report()["source"] == "source-only" diff --git a/tests/python/unit/codegen/test_hierarchy_scoped_solve_emit.py b/tests/python/unit/codegen/test_hierarchy_scoped_solve_emit.py index 77eff465c..48381cf91 100644 --- a/tests/python/unit/codegen/test_hierarchy_scoped_solve_emit.py +++ b/tests/python/unit/codegen/test_hierarchy_scoped_solve_emit.py @@ -29,7 +29,7 @@ def _coupled_model(name): u = model.primitive("u", mx / rho) v = model.primitive("v", my / rho) pressure = model.primitive("p", cs2 * rho) - model.primitive_vars(rho=rho, u=u, v=v, p=pressure) + model.primitive_vars(rho=rho, u=u, v=v) model.conservative_from([rho, rho * u, rho * v]) model.flux( x=[mx, mx * u + pressure, my * u], diff --git a/tests/python/unit/codegen/test_module_lowering.py b/tests/python/unit/codegen/test_module_lowering.py index 82ff7e347..b0ce47c11 100644 --- a/tests/python/unit/codegen/test_module_lowering.py +++ b/tests/python/unit/codegen/test_module_lowering.py @@ -33,7 +33,22 @@ from pops._ir.expr import Const # noqa: E402 from pops.physics._facade import Model # noqa: E402 from pops.codegen.module_lowering import ( # noqa: E402 - _module_to_model, lower_and_validate, remap_lowering_error) + _lower_native_role, _module_to_model, lower_and_validate, remap_lowering_error) +from pops.frames import X_AXIS, Z_AXIS # noqa: E402 +from pops.physics import Axial, Density, Momentum, Scalar # noqa: E402 +from pops.physics._coupled_abi import role_canonical # noqa: E402 +from pops.runtime._bricks_time import Role # noqa: E402 + + +def test_module_role_lowering_preserves_typed_boundary_semantics(): + assert _lower_native_role(Density()) == "Density" + assert _lower_native_role(Momentum(axis=X_AXIS)) == "MomentumX" + assert _lower_native_role(Axial(axis=X_AXIS)) == "AxialX" + assert _lower_native_role(Axial(axis=Z_AXIS)) == "AxialZ" + assert _lower_native_role(Scalar()) == "Scalar" + assert _lower_native_role("momentum_y") == "MomentumY" + assert _lower_native_role("Custom") is None + assert role_canonical("AxialZ") == Role.AxialZ == "axial_z" def _facade_model(name="ep"): diff --git a/tests/python/unit/codegen/test_recovery_admissibility_codegen.py b/tests/python/unit/codegen/test_recovery_admissibility_codegen.py new file mode 100644 index 000000000..16c33a1d9 --- /dev/null +++ b/tests/python/unit/codegen/test_recovery_admissibility_codegen.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import pytest + +from pops.physics import Model +from tests.python.support.physics_roles import FRAME, X_AXIS, Y_AXIS + + +def _scalar_model(*, guarded: bool) -> Model: + model = Model("scalar", frame=FRAME) + state = model.state("U", components=("q",)) + (primitive,) = state + model.flux( + "transport", + frame=FRAME, + state=state, + components={X_AXIS: (primitive,), Y_AXIS: (primitive,)}, + waves={X_AXIS: (1,), Y_AXIS: (1,)}, + ) + if guarded: + model.recovery_admissibility(q=primitive > 0) + return model + + +def test_recovery_admissibility_is_emitted_and_hashed() -> None: + guarded = _scalar_model(guarded=True) + plain = _scalar_model(guarded=False) + + generated = guarded._dsl._m.emit_cpp_brick(name="GuardedScalar") + assert "bool recovery_admissible(const Prim& P, int* failing_component_)" in generated + assert "const pops::Real q = P[0];" in generated + assert "*failing_component_ = 0;" in generated + assert "return false;" in generated + assert "recovery_admissible" not in plain._dsl._m.emit_cpp_brick(name="PlainScalar") + assert guarded._dsl._model_hash() != plain._dsl._model_hash() + + +def test_recovery_admissibility_rejects_ambiguous_authoring() -> None: + model = Model("guarded_scalar", frame=FRAME) + + with pytest.raises(ValueError, match=r"primitive_vars\(\.\.\.\) first"): + model.recovery_admissibility(q=1) + + state = model.state("U", components=("q",)) + (primitive,) = state + with pytest.raises(ValueError, match="unknown primitive components"): + model.recovery_admissibility(density=primitive > 0) + with pytest.raises(TypeError, match="typed symbolic Boolean expression"): + model.recovery_admissibility(q=1) + + model.recovery_admissibility(q=primitive > 0) + with pytest.raises(ValueError, match="policy already declared"): + model.recovery_admissibility(q=primitive >= 0) diff --git a/tests/python/unit/codegen/test_representation_arity.py b/tests/python/unit/codegen/test_representation_arity.py new file mode 100644 index 000000000..f74bad3b8 --- /dev/null +++ b/tests/python/unit/codegen/test_representation_arity.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import pytest + +from pops.physics._model import HyperbolicModel + + +def test_generated_model_refuses_unequal_primitive_and_conservative_arities() -> None: + model = HyperbolicModel("unequal_representation_arities") + first, second = model.conservative_vars("first", "second") + model.set_flux([first, second], [first, second]) + model.set_eigenvalues([0.0], [0.0]) + model.set_primitive_state(first) + model.set_conservative_from([first, first]) + + with pytest.raises( + ValueError, + match=( + r"primitive and conservative states must have equal arity " + r"\(got 1 primitive and 2 conservative components\)" + ), + ): + model.emit_cpp_brick() diff --git a/tests/python/unit/descriptors/test_descriptor_protocol.py b/tests/python/unit/descriptors/test_descriptor_protocol.py index f53fa91b4..4cd2214bf 100644 --- a/tests/python/unit/descriptors/test_descriptor_protocol.py +++ b/tests/python/unit/descriptors/test_descriptor_protocol.py @@ -146,19 +146,20 @@ def test_brick_descriptor_native_id_carried_in_lowering(): # A native brick lowers with its real C++ symbol; a test-only unavailable route carries none. assert HLL().lower().to_dict()["native_id"] == "pops::HLLFlux" planned = BrickDescriptor( - "mc", "native", category="limiter", native_id="", scheme="mc", available=False) + "planned_limiter", "native", category="limiter", native_id="", + scheme="planned_limiter", available=False) assert planned.lower().to_dict()["native_id"] in (None, "") matrix = planned.capability_matrix() row = matrix.rows[0] assert row.status == "unavailable" - assert "requested limiter:mc" in row.error_message + assert "requested limiter:planned_limiter" in row.error_message try: planned.validate() raise AssertionError("an unavailable descriptor must reject before bind/compile") except ValueError as exc: msg = str(exc) assert "unsupported route" in msg - assert "requested limiter:mc" in msg + assert "requested limiter:planned_limiter" in msg assert "available route" in msg assert "alternative" in msg diff --git a/tests/python/unit/descriptors/test_lib_descriptors.py b/tests/python/unit/descriptors/test_lib_descriptors.py index 8febab47e..d95a464d6 100644 --- a/tests/python/unit/descriptors/test_lib_descriptors.py +++ b/tests/python/unit/descriptors/test_lib_descriptors.py @@ -59,7 +59,6 @@ def test_unwired_placeholder_bricks_are_absent_from_final_catalogs(): for catalog, name in ( (lib.fields, "Poisson"), (lib.preconditioners, "Jacobi"), - (lib.limiters, "MC"), ): assert not hasattr(catalog, name) @@ -68,6 +67,13 @@ def test_unwired_placeholder_bricks_are_absent_from_final_catalogs(): assert newton.native_id == "pops::FieldNewtonSolver" +def test_mc_limiter_is_an_executable_native_descriptor(): + descriptor = lib.limiters.MC() + assert descriptor.available().ok is True + assert descriptor.native_id == "pops::MC" + assert descriptor.scheme == "mc" + + def test_available_native_ids_exist_and_are_namespaced(): for d in (lib.fields.GeometricMG(), lib.solvers.CG(max_iter=200), lib.solvers.GMRES(max_iter=200), diff --git a/tests/python/unit/domain/test_cartesian_domain_grid.py b/tests/python/unit/domain/test_cartesian_domain_grid.py index ea19d99de..b0fbc60cd 100644 --- a/tests/python/unit/domain/test_cartesian_domain_grid.py +++ b/tests/python/unit/domain/test_cartesian_domain_grid.py @@ -18,7 +18,7 @@ RectangleBoundaryNames, RectangleFrame, ) -from pops.frames import Cartesian2D, CartesianAxis, CartesianDirection +from pops.frames import Cartesian2D, CartesianAxis, CartesianDirection, Z_AXIS from pops.mesh.grid import CartesianGrid, PeriodicAxes @@ -38,9 +38,12 @@ def test_cartesian_axes_are_typed_immutable_and_canonical() -> None: assert y is frame.y assert (x.direction, x.index, x.name) == (CartesianDirection.X, 0, "x") assert (y.direction, y.index, y.name) == (CartesianDirection.Y, 1, "y") + assert (Z_AXIS.direction, Z_AXIS.index, Z_AXIS.name) == (CartesianDirection.Z, 2, "z") + assert Z_AXIS not in frame.axes assert len({x, y}) == 2 assert Cartesian2D.from_dict(frame.to_dict()) == frame assert CartesianAxis.from_dict(x.to_dict()) == x + assert CartesianAxis.from_dict(Z_AXIS.to_dict()) == Z_AXIS assert json.loads(json.dumps(frame.to_dict())) == frame.to_dict() with pytest.raises(FrozenInstanceError): diff --git a/tests/python/unit/fields/test_field_residual_solve_contract.py b/tests/python/unit/fields/test_field_residual_solve_contract.py index 75074417b..0edf26e14 100644 --- a/tests/python/unit/fields/test_field_residual_solve_contract.py +++ b/tests/python/unit/fields/test_field_residual_solve_contract.py @@ -44,6 +44,7 @@ BoundaryHandle, BoundaryOrientation, BoundaryProvider, + BoundaryProviderKind, BoundarySide, BoundaryTopology, CharacteristicClosure, @@ -109,7 +110,8 @@ def _provider(region, name, dependencies): return BoundaryProvider( _h("%s_provider" % name, "boundary_provider", CASE), (ConstraintResidual(boundary, dependencies.iterate, representation),), - provider_dependencies) + provider_dependencies, + BoundaryProviderKind.CONSTRAINT_RESIDUAL) def _contribution(cls, region, name, dependencies): diff --git a/tests/python/unit/mesh/test_boundary_topology_ports.py b/tests/python/unit/mesh/test_boundary_topology_ports.py index 602cd7648..fc9531e51 100644 --- a/tests/python/unit/mesh/test_boundary_topology_ports.py +++ b/tests/python/unit/mesh/test_boundary_topology_ports.py @@ -10,6 +10,7 @@ BoundaryHandle, BoundaryOrientation, BoundaryProvider, + BoundaryProviderKind, BoundaryProviderRegistry, BoundarySide, BoundaryTopology, @@ -30,6 +31,7 @@ Outflow, PeriodicIdentification, PeriodicOrientation, + PostRiemannFlux, RepresentationFlow, SignDependence, SonicPolicy, @@ -104,6 +106,10 @@ def _provider_handle(name): return Handle(name, kind="boundary_provider", owner=OwnerPath.case("main")) +def _flux_provider_handle(name): + return Handle(name, kind="boundary_flux_provider", owner=OwnerPath.case("main")) + + def _case_instance(case_name): return (OwnerPath.case(case_name) .child(OwnerKind.BLOCK, "transport") @@ -143,6 +149,21 @@ def test_topology_serializes_explicit_periodic_identification_and_physical_parti assert json.loads(json.dumps(topology.inspect())) == topology.inspect() +def test_topology_serializes_axis_permuted_xlo_to_yhi_identification(): + x_min, x_max, y_min, y_max = _boundaries() + periodic = PeriodicIdentification( + x_min, y_max, PeriodicOrientation((1, 0), (1, 1))) + topology = BoundaryTopology( + OwnerPath.case("main"), (x_min, x_max, y_min, y_max), + (periodic,), (x_max, y_min)) + + row = topology.canonical_identity()["periodic"][0] + assert row["source"]["orientation"]["axis"] == 0 + assert row["target"]["orientation"]["axis"] == 1 + assert row["orientation"] == { + "schema_version": 1, "permutation": [1, 0], "signs": [1, 1]} + + def test_topology_fails_loud_on_missing_double_extra_and_periodic_physical(): x_min, x_max, y_min, y_max = _boundaries() periodic = PeriodicIdentification( @@ -274,6 +295,17 @@ def test_named_provider_factories_are_data_only_and_port_typed(): dependencies=dependencies), ) assert all(type(row) is BoundaryProvider for row in providers) + assert [row.kind for row in providers] == [ + BoundaryProviderKind.INFLOW, + BoundaryProviderKind.OUTFLOW, + BoundaryProviderKind.GHOST_FORMULA, + BoundaryProviderKind.DIRICHLET, + BoundaryProviderKind.NEUMANN, + BoundaryProviderKind.MIXED, + ] + assert [row.canonical_identity()["provider_kind"] for row in providers] == [ + row.kind.value for row in providers + ] assert all(not hasattr(row, "callback") for row in providers) with pytest.raises(TypeError, match="typed ConstraintResidual"): Mixed(handle=_provider_handle("bad_mixed"), outputs=(ghost,), @@ -289,6 +321,8 @@ def test_noflux_satisfies_numerical_flux_only(): provider = NoFlux( handle=_provider_handle("no_flux"), output=flux, dependencies=_dependencies()) assert provider.outputs == (flux,) + assert provider.kind is BoundaryProviderKind.NO_FLUX + assert provider.canonical_identity()["provider_kind"] == "no_flux" assert BoundaryProviderRegistry(provider).resolve(_topology(), (flux,)).bindings with pytest.raises(TypeError, match="NumericalFlux only"): NoFlux(handle=_provider_handle("bad_no_flux"), output=ghost, @@ -296,6 +330,53 @@ def test_noflux_satisfies_numerical_flux_only(): with pytest.raises(ValueError, match="missing boundary provider"): BoundaryProviderRegistry().resolve(_topology(), (ghost,)) + with pytest.raises(TypeError, match="BoundaryProviderKind"): + BoundaryProvider( + _provider_handle("untyped_flux"), (flux,), _dependencies(), "no_flux") + with pytest.raises(TypeError, match="typed NumericalFlux"): + BoundaryProvider( + _provider_handle("forged_flux"), (ghost,), _dependencies(), + BoundaryProviderKind.NO_FLUX) + + +def test_post_riemann_flux_has_one_exact_typed_component_route(): + boundary = _topology().physical[0] + state, _, _ = _model_values() + _, conservative = _representations() + flux = NumericalFlux(boundary, state, conservative) + provider = PostRiemannFlux( + handle=_flux_provider_handle("wall_flux"), + output=flux, + dependencies=_dependencies(), + ) + + assert provider.outputs == (flux,) + assert provider.kind is BoundaryProviderKind.POST_RIEMANN_FLUX + assert provider.handle.kind == "boundary_flux_provider" + assert provider.canonical_identity()["provider_kind"] == "post_riemann_flux" + assert BoundaryProviderRegistry(provider).resolve(_topology(), (flux,)).bindings + + +def test_post_riemann_flux_refuses_wrong_component_route_or_output(): + boundary = _topology().physical[0] + state, _, _ = _model_values() + _, conservative = _representations() + flux = NumericalFlux(boundary, state, conservative) + ghost = GhostState(boundary, state, conservative) + + with pytest.raises(TypeError, match="boundary_flux_provider"): + PostRiemannFlux( + handle=_provider_handle("wrong_component_route"), + output=flux, + dependencies=_dependencies(), + ) + with pytest.raises(TypeError, match="NumericalFlux only"): + PostRiemannFlux( + handle=_flux_provider_handle("wrong_output"), + output=ghost, + dependencies=_dependencies(), + ) + def test_resolution_diagnostics_cover_missing_double_extra_ambiguous_and_periodic_physical(): topology = _topology() @@ -399,6 +480,7 @@ def test_every_semantic_field_is_immutable(): (dependencies, "time", ()), (dependencies, "runtime_params", ()), (dependencies, "representation", _dependencies().representation), (dependencies, "characteristic", _none_closure()), + (provider, "kind", BoundaryProviderKind.INFLOW), (provider, "handle", _provider_handle("other")), (provider, "outputs", ()), (provider, "dependencies", _dependencies()), (registry, "providers", ()), (plan.bindings[0], "need", port), (plan.bindings[0], "provider", provider), diff --git a/tests/python/unit/mesh/test_ghost_producer_plan.py b/tests/python/unit/mesh/test_ghost_producer_plan.py index 49a012bc2..b30b6c458 100644 --- a/tests/python/unit/mesh/test_ghost_producer_plan.py +++ b/tests/python/unit/mesh/test_ghost_producer_plan.py @@ -37,11 +37,13 @@ InterfaceSide, InterfaceTraceOperation, MultiBlockInterface, + NumericalFlux, NumericalClosure, PeriodicGhost, PeriodicIdentification, PeriodicOrientation, PhysicalGhost, + PostRiemannFlux, RepresentationFlow, SameLevelHaloMPI, SignDependence, @@ -350,6 +352,16 @@ def _physical_provider(boundary, name): dependencies=_none_dependencies()) +def _post_riemann_provider(boundary, name): + state = _h("U", "state", OwnerPath.model("transport")) + representation = _h("conservative", "representation") + return PostRiemannFlux( + handle=_h(name, "boundary_flux_provider", CASE), + output=NumericalFlux(boundary, state, representation), + dependencies=_none_dependencies(), + ) + + def _interface( topology, *, trace_provider="limiter.none", trace_operation=InterfaceTraceOperation.CELL_AVERAGE, required_depth=1): @@ -439,6 +451,47 @@ def test_all_explicit_producer_protocols_and_shared_interface_flux(): (GhostProduction(wrong_region, physical),)) +def test_physical_ghost_composes_trace_then_exact_post_riemann_flux_provider(): + topology = _topology() + boundary = topology.physical[0] + trace = _physical_provider(boundary, "wall_trace") + flux = _post_riemann_provider(boundary, "wall_flux") + producer = PhysicalGhost( + handle=_producer_handle("physical_flux"), + protocol=_protocol("physical"), + provider=trace, + flux_provider=flux, + ) + + assert set(producer.boundary_providers) == {trace, flux} + region = _region("physical_flux", boundary=boundary) + plan = GhostProducerRegistry(producer).resolve( + topology, + _coverage(region), + (region,), + (GhostProduction(region, producer),), + execution_authority=_ExecutableBoundaryAuthority(), + ) + with pytest.raises(NotImplementedError, match="BoundaryFlux components"): + plan.compile_boundary_data() + + other_face = next(row for row in topology.physical if row != boundary) + with pytest.raises(ValueError, match="same exact face"): + PhysicalGhost( + handle=_producer_handle("wrong_face"), + protocol=_protocol("physical"), + provider=trace, + flux_provider=_post_riemann_provider(other_face, "other_flux"), + ) + with pytest.raises(TypeError, match="PostRiemannFlux"): + PhysicalGhost( + handle=_producer_handle("wrong_law"), + protocol=_protocol("physical"), + provider=trace, + flux_provider=_physical_provider(boundary, "second_trace"), + ) + + @pytest.mark.parametrize( ("trace_provider", "required_depth"), (("limiter.minmod", 2), ("limiter.weno5", 3)), diff --git a/tests/python/unit/numerics/test_external_reconstruction_contract.py b/tests/python/unit/numerics/test_external_reconstruction_contract.py index 61b5f2eb5..0f90093ed 100644 --- a/tests/python/unit/numerics/test_external_reconstruction_contract.py +++ b/tests/python/unit/numerics/test_external_reconstruction_contract.py @@ -70,13 +70,17 @@ def test_builtin_reconstruction_contract_is_derived_from_generated_routes() -> N WENO5Z, authenticated_reconstruction_route, ) - from pops.numerics.reconstruction.limiters import Minmod, VanLeer + from pops.numerics.reconstruction.limiters import MC, Minmod, Superbee, VanLeer for descriptor, token, native_id, order, depth in ( (FirstOrder(), "none", "pops::NoSlope", 1, 1), (Minmod(), "minmod", "pops::Minmod", 2, 2), (VanLeer(), "vanleer", "pops::VanLeer", 2, 2), + (MC(), "mc", "pops::MC", 2, 2), + (Superbee(), "superbee", "pops::Superbee", 2, 2), (MUSCL(VanLeer()), "vanleer", "pops::VanLeer", 2, 2), + (MUSCL(MC()), "mc", "pops::MC", 2, 2), + (MUSCL(Superbee()), "superbee", "pops::Superbee", 2, 2), (WENO5(), "weno5", "pops::Weno5", 5, 3), (WENO5Z(), "weno5", "pops::Weno5", 5, 3), ): diff --git a/tests/python/unit/runtime/test_amr_checkpoint_contract.py b/tests/python/unit/runtime/test_amr_checkpoint_contract.py index d4c495fd4..f4ce7c36d 100644 --- a/tests/python/unit/runtime/test_amr_checkpoint_contract.py +++ b/tests/python/unit/runtime/test_amr_checkpoint_contract.py @@ -16,6 +16,7 @@ ) from pops.output._checkpoint_collective import restore_checkpoint_payload from pops.runtime._amr_checkpoint_contract import ( + checkpoint_temporal_partition_kind, contract_for, encode_contract, preflight_contract, @@ -102,6 +103,19 @@ def program_accepted_state_manifest(self): def program_clock_manifest(self): return [["level", "0", "4", "0", "1", "0.4"], ["logical", "clock.macro", "4"]] + def program_temporal_partition_manifest(self): + return [ + [ + "summary", + "global", + "pops.temporal-partition.global@1", + "0", + "0", + "1", + "0", + ] + ] + def program_flux_ledger_manifest(self): return [ [ @@ -176,7 +190,7 @@ def _payload(sim=None): def test_contract_names_guarantee_relations_qualified_histories_and_transfer_plans(): contract = contract_for(_Sim()) - assert contract["schema_version"] == 4 + assert contract["schema_version"] == 5 assert contract["guarantee"] == "bit_identical_accepted_state" assert contract["ledger"]["accepted_entries"] == 1 assert contract["ledger"]["transaction_depth"] == 0 @@ -210,6 +224,10 @@ def test_contract_names_guarantee_relations_qualified_histories_and_transfer_pla ] assert contract["transfer_routes"][0][2:5] == ["route.u", "provider.u", "kernel.linear"] assert contract["clocks"][1] == ["logical", "clock.macro", "4"] + assert contract["temporal_partition"][0][1:3] == [ + "global", + "pops.temporal-partition.global@1", + ] assert [row[3] for row in contract["synchronization"]] == ["reflux", "average_down"] @@ -219,6 +237,25 @@ def test_preflight_returns_exact_native_payload_and_counters(): assert (regrids, epoch) == (4, 7) +def test_checkpoint_temporal_partition_kind_is_strict_and_data_only(): + payload = _payload() + assert checkpoint_temporal_partition_kind(payload) == "global" + + data = json.loads(str(payload["amr_accepted_contract"])) + data["temporal_partition"] = [ + ["summary", "cell_local", "test.partition@1", "7", "8", "16", "2"], + ["rung", "0", "1"], + ["rung", "1", "1"], + ] + payload["amr_accepted_contract"] = np.array(json.dumps(data)) + assert checkpoint_temporal_partition_kind(payload) == "cell_local" + + data["temporal_partition"].append(["not-a-rung"]) + payload["amr_accepted_contract"] = np.array(json.dumps(data)) + with pytest.raises(ValueError, match="invalid rung row"): + checkpoint_temporal_partition_kind(payload) + + @pytest.mark.parametrize("mutation", ["ratio", "route", "guarantee"]) def test_preflight_refuses_any_static_provenance_mismatch(mutation): payload = _payload() @@ -241,6 +278,7 @@ def test_preflight_refuses_any_static_provenance_mismatch(mutation): "clocks", "ledger", "interface_ledger", + "temporal_partition", "synchronization", ], ) @@ -251,6 +289,8 @@ def test_dynamic_contract_is_checked_after_the_opaque_state_is_restored(section) data[section][0][1] = "program.block.1" elif section in {"ledger", "interface_ledger"}: data[section]["accepted_entries"] += 1 + elif section == "temporal_partition": + data[section][0][1] = "cell_local" else: data[section].append(["tampered"]) payload["amr_accepted_contract"] = np.array(json.dumps(data)) @@ -344,8 +384,7 @@ def program_sync_manifest(self): } assert ( - receipt["history_consensus_identity_before"] - != receipt["history_consensus_identity_after"] + receipt["history_consensus_identity_before"] != receipt["history_consensus_identity_after"] ) # Phase-local all-rank consensus is the contract: interpolation may legitimately change the # dense history image while conserved solution components are checked independently. @@ -437,14 +476,19 @@ def __getitem__(self, key): ], ) def test_uniform_and_amr_payload_versions_are_exact_current_integer_scalars( - runtime_kind, key, expected, + runtime_kind, + key, + expected, ): - assert require_exact_payload_version( - {key: np.array(expected, dtype=np.int64)}, - key=key, - expected=expected, - runtime_kind=runtime_kind, - ) == expected + assert ( + require_exact_payload_version( + {key: np.array(expected, dtype=np.int64)}, + key=key, + expected=expected, + runtime_kind=runtime_kind, + ) + == expected + ) for incompatible in ( np.array(True), @@ -487,7 +531,9 @@ def test_uniform_and_amr_payload_versions_are_exact_current_integer_scalars( ], ) def test_historical_version_refusal_happens_before_restart_transaction( - runtime_kind, key, expected, + runtime_kind, + key, + expected, ): calls = [] diff --git a/tests/python/unit/runtime/test_analytic_expression_lowering.py b/tests/python/unit/runtime/test_analytic_expression_lowering.py index e0f5b7122..423565180 100644 --- a/tests/python/unit/runtime/test_analytic_expression_lowering.py +++ b/tests/python/unit/runtime/test_analytic_expression_lowering.py @@ -3,7 +3,7 @@ import pytest import pops -from pops.analytic import angle, between, param, radius, sin, where, x +from pops.analytic import angle, between, param, radius, sin, time, where, x from pops.domain import Rectangle from pops.frames import Cartesian2D from pops.model import BindSchema @@ -133,6 +133,30 @@ def test_parameter_lowering_rejects_a_foreign_authenticated_schema() -> None: [expression.to_data()], frame_id=frame.canonical_id, bindings=bindings) +def test_time_lowers_only_for_the_exact_consuming_clock() -> None: + frame = Rectangle("time-domain", (0.0, 0.0), (1.0, 1.0)).frame(Cartesian2D()) + program = pops.Program("analytic-lowering-time") + expression = x(frame) + 2.0 * time(program.clock) + + ((opcodes, literals),) = lower_analytic_components( + [expression.to_data()], + frame_id=frame.canonical_id, + time_clock_id=program.clock.qualified_id, + ) + assert opcodes == ("x", "constant", "input", "mul", "add") + assert literals[2] == 0.0 + + with pytest.raises(NotImplementedError, match="exact physical-time Clock"): + lower_analytic_components([expression.to_data()], frame_id=frame.canonical_id) + other = pops.Program("analytic-lowering-other-time") + with pytest.raises(ValueError, match="another logical Clock"): + lower_analytic_components( + [expression.to_data()], + frame_id=frame.canonical_id, + time_clock_id=other.clock.qualified_id, + ) + + @pytest.mark.parametrize( "source", ( diff --git a/tests/python/unit/runtime/test_boundary_component_prepare_contract.py b/tests/python/unit/runtime/test_boundary_component_prepare_contract.py index 4eb48db89..63129ccf9 100644 --- a/tests/python/unit/runtime/test_boundary_component_prepare_contract.py +++ b/tests/python/unit/runtime/test_boundary_component_prepare_contract.py @@ -2,6 +2,7 @@ from __future__ import annotations from copy import deepcopy +from pathlib import Path from types import SimpleNamespace import pytest @@ -10,6 +11,23 @@ from pops.runtime._runtime_authorities import install_runtime_authorities +ROOT = Path(__file__).resolve().parents[4] + + +def test_native_boundary_install_has_no_component_count_compatibility_abi(): + old_scalar_adapter = "const std::vector& face_values, int ncomp" + typed_roles = "const std::vector& component_roles" + for relative in ( + "include/pops/runtime/system.hpp", + "include/pops/runtime/amr_system.hpp", + "src/runtime/system/system_install.cpp", + "src/runtime/amr/amr_system.cpp", + ): + source = (ROOT / relative).read_text(encoding="utf-8") + assert old_scalar_adapter not in source + assert typed_roles in source + + def _execution_context() -> ExecutionContext: backend = proven_serial_manifest( backend="production", target="system", abi="test|clang++|c++23", runtime=True) @@ -22,10 +40,19 @@ def _execution_context() -> ExecutionContext: @pytest.mark.parametrize("prepare_fails", (False, True)) -def test_boundary_component_install_is_transactional_and_preserves_prepare_json(prepare_fails): +@pytest.mark.parametrize( + ("operation", "native_interface", "expected_installer"), + ( + ("apply_region_batch", {"abi_id": 17, "version": 1, + "cpp_table": "GhostBoundary"}, "ghost"), + ("transform_faces", {"abi_id": 6, "version": 1, + "cpp_table": "BoundaryFlux"}, "flux"), + ), +) +def test_boundary_component_install_is_transactional_and_preserves_prepare_json( + prepare_fails, operation, native_interface, expected_installer): component_id = "pops://external.test/boundary@1.0.0" manifest_identity = "component-manifest:boundary-test" - native_interface = {"abi_id": 17, "version": 1, "cpp_table": "GhostBoundary"} region = { "kind": "face", "dimension": 2, "codimension": 1, "axes": [0], "sides": [-1], "identity": "left-face", @@ -38,7 +65,7 @@ def test_boundary_component_install_is_transactional_and_preserves_prepare_json( "interface_version": 1, "region": region, "parameters": [{"qualified_id": "case::inlet", "value": 2.0}], - "operation": "apply_region_batch", + "operation": operation, "state_identity": "case::block::state", "states": [], "directions": [], "fields": [], "outputs": ["case::block::state"], @@ -50,7 +77,12 @@ def test_boundary_component_install_is_transactional_and_preserves_prepare_json( "state": {"qualified_id": "case::block::state"}, "required_depth": 1, "faces": [ - {"ordinal": ordinal, "type": "foextrap", "values": [0.0]} + { + "ordinal": ordinal, + "producer": "case::block::boundary::face::%d" % ordinal, + "type": "foextrap", + "values": [0.0], + } for ordinal in range(4) ], "omitted_interface_faces": [], @@ -69,6 +101,7 @@ def __init__(self): self.prepare_overrides = None self.discarded = False self.state_routes = [] + self.installer = None def _install_block_state_route(self, block, identity): self.state_routes.append((block, identity)) @@ -81,10 +114,21 @@ def _discard_boundary_plans(self): def _install_ghost_boundary_component( self, block, handle, row, parameters_json, target_json, execution): + self._install_component( + "ghost", block, handle, row, parameters_json, target_json, execution) + + def _install_boundary_flux_component( + self, block, handle, row, parameters_json, target_json, execution): + self._install_component( + "flux", block, handle, row, parameters_json, target_json, execution) + + def _install_component( + self, installer, block, handle, row, parameters_json, target_json, execution): assert block == "block" assert handle is native_handle assert row == component_row assert execution["communicator_identity"] == "serial" + self.installer = installer self.prepare_overrides = (parameters_json, target_json) if prepare_fails: raise RuntimeError("component prepare rejected") @@ -109,7 +153,8 @@ class BoundaryBlock: interface=Interface(), native_handle=native_handle, ) artifact = SimpleNamespace( - blocks=(SimpleNamespace(name="block", model=SimpleNamespace(n_vars=1)),), + blocks=(SimpleNamespace( + name="block", model=SimpleNamespace(n_vars=1, cons_roles=("Scalar",))),), plan=SimpleNamespace(blocks=(BoundaryBlock(),), field_plans={}), layout_plan=SimpleNamespace(layouts=(SimpleNamespace(adaptive=False),)), ) @@ -128,9 +173,20 @@ class BoundaryBlock: assert native.state_routes == [("block", "case::block::state")] assert native.discarded is False assert native.prepare_overrides == ("", "") + assert native.installer == expected_installer -def test_signed_periodic_identification_reaches_native_install_without_callback(): +@pytest.mark.parametrize( + ("target_axis", "target_face", "permutation", "signs", "face_types"), + ( + (0, 1, [0, 1], [1, -1], + ["periodic", "periodic", "dirichlet", "foextrap"]), + (1, 3, [1, 0], [1, 1], + ["periodic", "foextrap", "dirichlet", "periodic"]), + ), +) +def test_signed_periodic_identification_reaches_native_install_without_callback( + target_axis, target_face, permutation, signs, face_types): def boundary_identity(name, axis, side): return { "qualified_id": "case::%s" % name, @@ -143,7 +199,7 @@ def boundary_identity(name, axis, side): } source = boundary_identity("xlo", 0, "lower") - target = boundary_identity("xhi", 0, "upper") + target = boundary_identity("target", target_axis, "upper") runtime_data = { "schema_version": 1, "authority_type": "prepared_boundary_plan", @@ -153,8 +209,15 @@ def boundary_identity(name, axis, side): "faces": [ { "ordinal": ordinal, - "type": "periodic" if ordinal < 2 else "foextrap", + "producer": "case::block::reflected-periodic::face::%d" % ordinal, + "type": face_types[ordinal], + "representation": "conservative", "values": [0.0], + "analytic_programs": ( + [{"opcodes": ["x", "input", "add"], "literals": [0.0, 0.0, 0.0]}] + if ordinal == 2 else [] + ), + "analytic_clock": "clock.analytic" if ordinal == 2 else None, } for ordinal in range(4) ], @@ -163,9 +226,9 @@ def boundary_identity(name, axis, side): "source": source, "target": target, "source_face": 0, - "target_face": 1, - "permutation": [0, 1], - "signs": [1, -1], + "target_face": target_face, + "permutation": permutation, + "signs": signs, }], "component_regions": [], "interface_component_bindings": [], @@ -198,7 +261,8 @@ class BoundaryBlock: native = Native() engine = SimpleNamespace(_s=native) artifact = SimpleNamespace( - blocks=(SimpleNamespace(name="block", model=SimpleNamespace(n_vars=1)),), + blocks=(SimpleNamespace( + name="block", model=SimpleNamespace(n_vars=1, cons_roles=("Scalar",))),), plan=SimpleNamespace(blocks=(BoundaryBlock(),), field_plans={}), layout_plan=SimpleNamespace(layouts=(SimpleNamespace(adaptive=False),)), ) @@ -212,5 +276,17 @@ class BoundaryBlock: install_runtime_authorities(engine, install_plan) assert native.installed is not None - assert native.installed[3] == ["periodic", "periodic", "foextrap", "foextrap"] - assert native.installed[8] == [[0, 1, 0, 1, 1, -1]] + assert native.installed[3] == face_types + assert native.installed[5] == [ + "case::block::reflected-periodic::face::0", + "case::block::reflected-periodic::face::1", + "case::block::reflected-periodic::face::2", + "case::block::reflected-periodic::face::3", + ] + assert native.installed[6] == ["Scalar"] + assert native.installed[9] == [[0, target_face, *permutation, *signs]] + assert native.installed[10] == ["conservative"] * 4 + assert native.installed[11] == [""] * 4 + assert native.installed[12] == [[], [], ["x", "input", "add"], []] + assert native.installed[13] == [[], [], [0.0, 0.0, 0.0], []] + assert native.installed[14] == ["", "", "clock.analytic", ""] diff --git a/tests/python/unit/runtime/test_destring_finite_volume.py b/tests/python/unit/runtime/test_destring_finite_volume.py index 3e1763be5..cb078072e 100644 --- a/tests/python/unit/runtime/test_destring_finite_volume.py +++ b/tests/python/unit/runtime/test_destring_finite_volume.py @@ -25,7 +25,7 @@ from pops.numerics.riemann import Rusanov, HLL, HLLC, Roe # noqa: E402 from pops.numerics.reconstruction import FirstOrder, MUSCL, WENO5, WENO5Z # noqa: E402 -from pops.numerics.reconstruction.limiters import Minmod, VanLeer # noqa: E402 +from pops.numerics.reconstruction.limiters import MC, Minmod, Superbee, VanLeer # noqa: E402 from pops.numerics.variables import Conservative, Primitive # noqa: E402 @@ -91,9 +91,12 @@ def test_typed_flux_descriptors_lower(): def test_typed_limiter_descriptors_lower(): cases = ((FirstOrder(), "none"), (Minmod(), "minmod"), (VanLeer(), "vanleer"), + (MC(), "mc"), (Superbee(), "superbee"), (WENO5(), "weno5"), (WENO5Z(), "weno5"), (MUSCL(limiter=Minmod()), "minmod"), - (MUSCL(limiter=VanLeer()), "vanleer")) + (MUSCL(limiter=VanLeer()), "vanleer"), + (MUSCL(limiter=MC()), "mc"), + (MUSCL(limiter=Superbee()), "superbee")) for desc, token in cases: s = engine.Spatial(limiter=desc) assert s.limiter == token, (desc, s.limiter) @@ -111,6 +114,17 @@ def test_combined_typed_spatial(): assert (s.limiter, s.flux, s.recon) == ("vanleer", "hllc", "primitive") +def test_mc_and_superbee_share_the_prepared_spatial_route() -> None: + for limiter, token in ((MC(), "mc"), (Superbee(), "superbee")): + for variables, variables_token in ( + (Conservative(), "conservative"), (Primitive(), "primitive") + ): + spatial = engine.Spatial(limiter=limiter, flux=Roe(), recon=variables) + assert spatial.limiter.id == "limiter.%s" % token + assert spatial.limiter.native_entry == limiter.native_id + assert (spatial.flux, spatial.recon) == ("roe", variables_token) + + def test_defaults_are_canonical(): s = engine.Spatial() assert (s.limiter, s.flux, s.recon) == ("minmod", "rusanov", "conservative") diff --git a/tests/python/unit/runtime/test_numerical_defaults_reports.py b/tests/python/unit/runtime/test_numerical_defaults_reports.py index f79c4d174..6a40a0d39 100644 --- a/tests/python/unit/runtime/test_numerical_defaults_reports.py +++ b/tests/python/unit/runtime/test_numerical_defaults_reports.py @@ -76,7 +76,6 @@ def test_system_inspect_reports_effective_block_and_solver_options(): newton_rel_tol=1e-6, newton_fd_eps=2e-7, newton_damping=0.8, - newton_diagnostics=True, ), spatial=engine.Spatial(positivity_floor=1e-12), ) @@ -97,12 +96,25 @@ def test_system_inspect_reports_effective_block_and_solver_options(): assert block["newton"]["rel_tol"] == pytest.approx(1e-6) assert block["newton"]["fd_eps"] == pytest.approx(2e-7) assert "fail_policy" not in block["newton"] - assert block["newton"]["diagnostics"] is True + assert block["newton"]["diagnostics"] is False assert block["physical"]["cs2"] == pytest.approx(0.7) assert block["physical"]["q"] == pytest.approx(-2.0) assert block["positivity_floor"] == pytest.approx(1e-12) +def test_system_rejects_unpublished_newton_diagnostics(): + sim = System(n=8, L=1.0, periodicity=(True, True)) + with pytest.raises( + ValueError, + match="no typed implicit Program consumer publishes that report", + ): + sim.add_equation( + "ion", + _isothermal_model(), + time=engine.IMEX(newton_diagnostics=True), + ) + + def test_invalid_newton_values_are_rejected(): with pytest.raises(ValueError, match="newton_max_iters"): engine.IMEX(newton_max_iters=0) diff --git a/tests/python/unit/runtime/test_program_report.py b/tests/python/unit/runtime/test_program_report.py index 005d933eb..1d948b959 100644 --- a/tests/python/unit/runtime/test_program_report.py +++ b/tests/python/unit/runtime/test_program_report.py @@ -4,6 +4,7 @@ executor. These unit checks exercise the single report owner directly: no legacy ``System`` is constructed and no native state array is read. """ + from __future__ import annotations import json @@ -82,8 +83,14 @@ def checkpoint_temporal_relations(self): return [(0, 1, 1, 2, "exact")] def program_flux_ledger_manifest(self): - return [("fluid", "U", "rhs", "transport", 1, 4, 1, 2, 1, 2, - "outward", 0.25, 0.125)] + return [("fluid", "U", "rhs", "transport", 1, 4, 1, 2, 1, 2, "outward", 0.25, 0.125)] + + def program_temporal_partition_manifest(self): + return [ + ("summary", "cell_local", "test.partition@1", 7, 16, 32, 5), + ("rung", 0, 3), + ("rung", 1, 2), + ] def program_sync_manifest(self): return [(0, 1, 0, "reflux", 4, 1, 2)] @@ -108,6 +115,7 @@ def test_empty_authority_produces_an_honest_empty_report(): assert report.level_relations == [] assert report.flux_ledger == [] assert report.synchronization == [] + assert report.temporal_partition == {} assert report.temporal == {} assert report.profiler == {"enabled": None} @@ -123,23 +131,44 @@ def test_accepted_program_report_preserves_owned_metadata(): assert report.params[0]["count"] == 2 assert report.params[0]["limit"] > 0 assert report.diagnostics == {"mass": 3.5} - assert report.histories == [{ - "name": "u_prev", "depth": 2, "ncomp": 3, "initialized": True, - }] - assert report.cache == [{ - "node_id": 7, - "name": "stage_rhs", - "last_update_step": 4, - "accumulated_dt": 0.125, - }] + assert report.histories == [ + { + "name": "u_prev", + "depth": 2, + "ncomp": 3, + "initialized": True, + } + ] + assert report.cache == [ + { + "node_id": 7, + "name": "stage_rhs", + "last_update_step": 4, + "accumulated_dt": 0.125, + } + ] assert report.clocks == [ {"kind": "logical", "clock": "main", "tick": 4}, - {"kind": "level", "level": 1, "macro_step": 4, - "phase": {"numerator": 1, "denominator": 2}, "physical_time": 0.5}, + { + "kind": "level", + "level": 1, + "macro_step": 4, + "phase": {"numerator": 1, "denominator": 2}, + "physical_time": 0.5, + }, ] assert report.level_relations[0]["remainder_policy"] == "exact" assert report.flux_ledger[0]["flux"] == "transport" assert report.synchronization[0]["phase"] == "reflux" + assert report.temporal_partition == { + "kind": "cell_local", + "provider_identity": "test.partition@1", + "topology_epoch": 7, + "synchronization_tick": 16, + "tick_denominator": 32, + "cell_count": 5, + "rungs": [{"rung": 0, "cells": 3}, {"rung": 1, "cells": 2}], + } assert report.temporal == {"schema_version": 1, "accepted_step": 4} @@ -147,7 +176,7 @@ def test_report_serialization_is_array_free_and_detached(): report = build_program_report(_AcceptedProgramAuthority()) data = report.to_dict() - assert data["schema_version"] == 3 + assert data["schema_version"] == 4 assert data["report_type"] == "program_runtime" assert json.loads(report.to_json()) == data assert "accepted-program" in str(report) diff --git a/tests/python/unit/time/test_program_solve_final.py b/tests/python/unit/time/test_program_solve_final.py index b1a7f8e2f..caa923b32 100644 --- a/tests/python/unit/time/test_program_solve_final.py +++ b/tests/python/unit/time/test_program_solve_final.py @@ -272,8 +272,8 @@ def test_final_catalogs_do_not_publish_unavailable_placeholders(): assert not hasattr(fields, "Helmholtz") assert not hasattr(fields, "EllipticSolve") assert not hasattr(projections, "bound_preserving") - assert not hasattr(limiters, "MC") - assert not hasattr(limiters, "Superbee") + assert hasattr(limiters, "MC") + assert hasattr(limiters, "Superbee") assert not hasattr(preconditioners, "Jacobi") assert not hasattr(preconditioners, "BlockJacobi") assert not hasattr(solvers, "Schur") diff --git a/tests/python/unit/time/test_time_control_flow.py b/tests/python/unit/time/test_time_control_flow.py index 37f9475c5..bd67bcaf8 100644 --- a/tests/python/unit/time/test_time_control_flow.py +++ b/tests/python/unit/time/test_time_control_flow.py @@ -168,8 +168,7 @@ def _run_section_b(t): def passive_model(name): m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) # passive advection at speed 0 (the Program never runs a rhs) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/unit/time/test_time_control_flow_b.py b/tests/python/unit/time/test_time_control_flow_b.py index b9bd8ad12..91c2f87c0 100644 --- a/tests/python/unit/time/test_time_control_flow_b.py +++ b/tests/python/unit/time/test_time_control_flow_b.py @@ -191,8 +191,7 @@ def _passive_model(name): from pops.physics._facade import Model m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/unit/time/test_time_divergence.py b/tests/python/unit/time/test_time_divergence.py index be4488a82..a9e071871 100644 --- a/tests/python/unit/time/test_time_divergence.py +++ b/tests/python/unit/time/test_time_divergence.py @@ -243,8 +243,7 @@ def _run_section_b(t): def passive_model(name): # 1-variable block, no flux, no Poisson coupling m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/unit/time/test_time_gmres.py b/tests/python/unit/time/test_time_gmres.py index 6f55efa31..b6b6e802c 100644 --- a/tests/python/unit/time/test_time_gmres.py +++ b/tests/python/unit/time/test_time_gmres.py @@ -419,8 +419,7 @@ def _passive_model(name): from pops.physics._facade import Model m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/unit/time/test_time_history.py b/tests/python/unit/time/test_time_history.py index 58871b86e..c971a2d54 100644 --- a/tests/python/unit/time/test_time_history.py +++ b/tests/python/unit/time/test_time_history.py @@ -280,8 +280,7 @@ def _passive_source_model(name): from pops.physics._facade import Model m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/unit/time/test_time_local_newton.py b/tests/python/unit/time/test_time_local_newton.py index f1bdc6401..8245b63e8 100644 --- a/tests/python/unit/time/test_time_local_newton.py +++ b/tests/python/unit/time/test_time_local_newton.py @@ -71,8 +71,7 @@ def reaction_model(name, k): m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) @@ -114,8 +113,7 @@ def fault_model(name): m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/unit/time/test_time_local_solve_run.py b/tests/python/unit/time/test_time_local_solve_run.py index 37858139d..9b14ff3dc 100644 --- a/tests/python/unit/time/test_time_local_solve_run.py +++ b/tests/python/unit/time/test_time_local_solve_run.py @@ -82,7 +82,7 @@ def lorentz_model(name="lorentz_local"): u = m.primitive("u", mx / rho) v = m.primitive("v", my / rho) p = m.primitive("p", cs2 * rho) - m.primitive_vars(rho=rho, u=u, v=v, p=p) + m.primitive_vars(rho=rho, u=u, v=v) m.conservative_from([rho, rho * u, rho * v]) m.flux(x=[mx, mx * u + p, my * u], y=[my, mx * v, my * v + p]) cs = sqrt(cs2) diff --git a/tests/python/unit/time/test_time_multiblock.py b/tests/python/unit/time/test_time_multiblock.py index b689221a0..ab4328cdf 100644 --- a/tests/python/unit/time/test_time_multiblock.py +++ b/tests/python/unit/time/test_time_multiblock.py @@ -86,9 +86,7 @@ def passive_model(name): m = Model(name) (rho,) = m.conservative_vars("rho") a = 0.7 # constant advection speed (x and y) - u = m.primitive("u", a + 0.0 * rho) - v = m.primitive("v", a + 0.0 * rho) - m.primitive_vars(rho=rho, u=u, v=v) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[a * rho], y=[a * rho]) # F = a*rho (linear advection) m.eigenvalues(x=[a + 0.0 * rho], y=[a + 0.0 * rho]) diff --git a/tests/python/unit/time/test_time_multielliptic.py b/tests/python/unit/time/test_time_multielliptic.py index 8652a6742..256bbf47f 100644 --- a/tests/python/unit/time/test_time_multielliptic.py +++ b/tests/python/unit/time/test_time_multielliptic.py @@ -193,7 +193,7 @@ def _block(m): u = m.primitive("u", mx / rho) v = m.primitive("v", my / rho) p = m.primitive("p", cs2 * rho) - m.primitive_vars(rho=rho, u=u, v=v, p=p) + m.primitive_vars(rho=rho, u=u, v=v) m.conservative_from([rho, rho * u, rho * v]) cs = sqrt(cs2) m.eigenvalues(x=[u - cs, u, u + cs], y=[v - cs, v, v + cs]) diff --git a/tests/python/unit/time/test_time_solve_linear.py b/tests/python/unit/time/test_time_solve_linear.py index c111bd68d..df131a08a 100644 --- a/tests/python/unit/time/test_time_solve_linear.py +++ b/tests/python/unit/time/test_time_solve_linear.py @@ -415,8 +415,7 @@ def test_native_compiled_cg_matches_offline_periodic_helmholtz(t): def passive_model(name): m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) @@ -506,8 +505,7 @@ def test_native_gmres_geometric_mg_matches_offline_periodic_helmholtz(t): def passive_model(name): m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/unit/time/test_time_std_rk.py b/tests/python/unit/time/test_time_std_rk.py index e34ecb616..d2a3b9f05 100644 --- a/tests/python/unit/time/test_time_std_rk.py +++ b/tests/python/unit/time/test_time_std_rk.py @@ -127,8 +127,7 @@ def _passive_model(name): from pops.physics._facade import Model model = Model(name) (rho,) = model.conservative_vars("rho") - velocity = model.primitive("u", 0.0 * rho) - model.primitive_vars(rho=rho, u=velocity) + model.primitive_vars(rho) model.conservative_from([rho]) model.flux(x=[0.0 * rho], y=[0.0 * rho]) model.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/unit/time/test_time_where.py b/tests/python/unit/time/test_time_where.py index 09dce1b43..512d1d9da 100644 --- a/tests/python/unit/time/test_time_where.py +++ b/tests/python/unit/time/test_time_where.py @@ -198,8 +198,7 @@ def _run_section_b(t): def passive_model(name): m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) # passive advection at speed 0 (the Program never runs a rhs) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/test_manifest.toml b/tests/test_manifest.toml index 3b55de01e..b9f2dd70f 100644 --- a/tests/test_manifest.toml +++ b/tests/test_manifest.toml @@ -165,6 +165,12 @@ sources = ["tests/cpp/integration/mpi/test_mpi_amr_dynamic_active_depth.cpp"] labels = ["backend", "mpi", "medium"] mpi_nproc = [1, 2, 4] +[[cpp.suite]] +name = "test_mpi_amr_prepared_boundary_cf" +sources = ["tests/cpp/integration/mpi/test_mpi_amr_prepared_boundary_cf.cpp"] +labels = ["backend", "mpi", "medium"] +mpi_nproc = [1, 2, 4] + [[cpp.suite]] name = "test_mpi_amr_program_reflux" sources = ["tests/cpp/integration/mpi/test_mpi_amr_program_reflux.cpp"] @@ -882,6 +888,11 @@ name = "test_positivity_floor" sources = ["tests/cpp/unit/numerics/test_positivity_floor.cpp"] labels = ["unit", "numerics", "fast"] +[[cpp.suite]] +name = "test_prepared_numerics_gate" +sources = ["tests/cpp/unit/numerics/test_prepared_numerics_gate.cpp"] +labels = ["unit", "numerics", "fast"] + [[cpp.suite]] name = "test_primitive_recon" sources = ["tests/cpp/unit/numerics/test_primitive_recon.cpp"] @@ -902,6 +913,11 @@ name = "test_splitting" sources = ["tests/cpp/unit/numerics/test_splitting.cpp"] labels = ["unit", "numerics", "fast"] +[[cpp.suite]] +name = "test_variable_recovery_chain" +sources = ["tests/cpp/unit/numerics/test_variable_recovery_chain.cpp"] +labels = ["unit", "numerics", "fast"] + [[cpp.suite]] name = "test_weno5_ssprk3" sources = ["tests/cpp/unit/numerics/test_weno5_ssprk3.cpp"] @@ -1134,6 +1150,11 @@ name = "test_program_reflux_ledger" sources = ["tests/cpp/integration/amr/test_program_reflux_ledger.cpp"] labels = ["integration", "amr", "medium"] +[[cpp.suite]] +name = "test_temporal_partition_restart" +sources = ["tests/cpp/integration/amr/test_temporal_partition_restart.cpp"] +labels = ["integration", "runtime", "amr", "medium"] + [[cpp.suite]] name = "test_residual_operator" sources = ["tests/cpp/unit/runtime/test_residual_operator.cpp"]