Fill Values
Table of Contents
1 Overview
Omega supports output of ocean fields to NetCDF files via I/O streams. Each field
carries a declared fill value stored as metadata under the _FillValue attribute,
which marks entries that are undefined or outside the active domain. Currently, fill
value constants are declared locally in each module with inconsistent values
(typically -9.99e30 for real-valued fields, -999 for integers). These values do
not match the NetCDF-C standard fill values that analysis tools and visualization
software commonly expect. Additionally, the Kokkos arrays backing these fields are
never explicitly initialized with fill values: inactive ocean layers (model layers
below MaxLayerCell) hold uninitialized memory rather than a well-defined sentinel,
and the distinction between “no data” and “zero” is lost.
This design standardizes fill values across all Omega fields to match the NetCDF-C
standard, ensures that field arrays are automatically initialized with the correct
fill value when data is attached to a Field, and verifies through CTests that the
resulting behavior is correct for inactive layers and for edge/vertex fields at
domain boundaries.
2 Requirements
2.1 Requirement: Centralized fill value constants
Fill value constants must be defined in a single location in Omega that is accessible
to all modules. Scatter of locally-declared constants has produced inconsistencies
(e.g., -9.99e30 vs -9.99E+30) and makes future maintenance error-prone.
2.2 Requirement: Standard fill values matching NetCDF-C
Fill value constants must exactly match the NetCDF-C standard values (NC_FILL_*)
as exposed by the SCORPIO library (PIO_FILL_* in pio.h). Using standard values
ensures compatibility with NetCDF-aware tools (ncview, Xarray, NCO, etc.) that
recognize and handle fill values automatically. The required values are:
Type |
Omega name |
Value |
|---|---|---|
I4 |
|
-2147483647 ( |
I8 |
|
|
R4 |
|
9.9692099683868690e+36f ( |
R8 |
|
9.9692099683868690e+36 ( |
Real |
|
Alias for |
2.3 Requirement: Automatic array initialization at attach time
When Field::attachData() is called, the attached Kokkos array must be
automatically initialized with the standard fill value for the array’s element
type before any compute routine runs. The fill value is deduced from
typename T::value_type at attachData() time using the constants in
FillValues.h (FillValueI4, FillValueI8, FillValueR4, or FillValueR8),
so the caller does not need to specify it. This guarantees that inactive ocean
layers (below MaxLayerCell) and any other undefined entries contain a
well-defined sentinel in output, without requiring each module to manually
perform the fill.
2.4 Requirement: No fill value argument in Field::create()
The FillValue argument is removed from Field::create(). Because the fill
value is always one of the standard constants determined by the array element
type, specifying it at field creation time is redundant. The fill value and the
_FillValue metadata entry are set automatically when attachData() is called.
2.5 Requirement: Inactive layers contain fill values in output
After compute routines execute, model layers with index k > MaxLayerCell(ICell)
must contain the declared fill value in output. With automatic initialization at
attach time (Requirement 2.3) and compute routines that only write to active layers,
this is satisfied without changes to individual compute functions.
2.6 Desired: Valid values at domain boundary edges and vertices
Edge and vertex fields at valid boundaries adjacent to land or to bathymetry must
contain computed valid values, not fill values. Switching from zero-initialization
to fill-value initialization must not silently break any boundary computation that
previously assumed zero defaults. This mirrors a problem encountered in MPAS-Ocean
when initializing NormalVelocity with a fill value rather than zero: code that
implicitly assumed zero at land boundaries produced incorrect results. During
implementation, all compute routines for edge/vertex fields must be audited to
confirm that every valid boundary entry receives an explicit computed value. As an
example of what such an audit can reveal: TracerHorzAdvOnCell accumulated its
internal working array HighOrderFlxHorz over a cell’s full active layer range
without clamping to each contributing edge’s active range, reading fill values from
edges shallower than the cell — see Section 4.5 for the resolution.
2.7 Requirement: CTests
CTests must verify Requirements 2.1–2.3, 2.5, and Desired Requirement 2.6. The
three-zone boundary behavior (2.6) is exercised for NormalVelocity via the edge
mask; the two-zone cell and vertex masks are verified with synthetic cell and
vertex fields.
3 Algorithmic Formulation
No new numerical algorithm is introduced. The key behavioral change is in
Field::attachData<T>(), which gains a fill step equivalent to:
Kokkos::deep_copy(InDataArray, fill_scalar);
where fill_scalar is the typed fill value extracted from the field’s FieldMeta
map (stored as std::any under key "_FillValue"), and the element type
typename T::value_type of the Kokkos view determines the std::any_cast type.
Kokkos::deep_copy with a scalar source broadcasts that value to every element of
the view and dispatches correctly for both host and device memory spaces.
4 Design
4.1 Data types and parameters
4.1.1 Constants: FillValue definitions in FillValues.h
The fill value constants are defined in a new lightweight header
components/omega/src/base/FillValues.h. The values match the NetCDF-C NC_FILL_*
constants (which are identical to the SCORPIO PIO_FILL_* constants), but are
written as numeric literals so that the header can be included from any context
without pulling in pio.h or mpi.h, which carry strict include-order requirements.
A type-indexed variable template FillValue<T> is the primary definition. Its
primary template is intentionally left undefined so that instantiation with an
unsupported type produces a link error. Explicit specializations are provided for
each supported scalar type:
template <typename T>
constexpr T FillValue; // primary template intentionally undefined
template <> inline constexpr I4 FillValue<I4> = -2147483647; // NC_FILL_INT
template <> inline constexpr I8 FillValue<I8> = -9223372036854775806LL; // NC_FILL_INT64
template <> inline constexpr R4 FillValue<R4> = 9.9692099683868690e+36f; // NC_FILL_FLOAT
template <> inline constexpr R8 FillValue<R8> = 9.9692099683868690e+36; // NC_FILL_DOUBLE
Named aliases are provided for readability in comparison and test code:
constexpr I4 FillValueI4 = FillValue<I4>;
constexpr I8 FillValueI8 = FillValue<I8>;
constexpr R4 FillValueR4 = FillValue<R4>;
constexpr R8 FillValueR8 = FillValue<R8>;
#if defined(SINGLE_PRECISION)
constexpr Real FillValueReal = FillValue<R4>;
#else
constexpr Real FillValueReal = FillValue<R8>;
#endif
FillValues.h is included from Field.h. Because every module that creates or
uses fields already includes Field.h (directly or transitively), no additional
include directives are needed in module source files.
4.1.2 Class/struct changes
No new classes or structs are introduced. The changes are confined to:
new header
FillValues.hwith constants (Section 4.1.1)FillValues.hincluded fromField.hthe
attachDatatemplate methods inField.h/Field.cpp(Section 4.2)removal of local fill value declarations in module files (Section 4.3)
4.2 Methods
4.2.1 Field::attachData() auto-fill
The attachData<T>() instance method and the attachFieldData<T>() static helper
in components/omega/src/infra/Field.h gain a private fill step controlled by an
optional FillOnAttach parameter (default true):
template <typename T>
void attachData(const T &InDataArray, bool FillOnAttach = true) {
OMEGA_ASSERT(isKokkosArray<T>,
"Field::attachData requires Kokkos array as input");
DataArray = std::make_shared<T>(InDataArray);
DataType = checkArrayType<T>();
MemLoc = findArrayMemLoc<T>();
if (FillOnAttach)
fillWithValue<T>(InDataArray); // auto-fill with type-deduced FillValue
}
template <typename T>
static void attachFieldData(const std::string &FieldName,
const T &InDataArray, bool FillOnAttach = true);
The default FillOnAttach = true preserves the fill behavior for the normal
initialization workflow (fresh arrays attached before any data is written). Pass
FillOnAttach = false when re-attaching an existing data-filled array where the
fill must not overwrite computed values — see Section 4.2.3 for the canonical use case.
The private helper fillWithValue<T>() performs the following steps:
Derives the element type as
typename T::non_const_value_type(using the non-const form so that const view types resolve to the same underlying type).Looks up the fill value via
FillValue<ValType>, a constexpr variable template specialization fromFillValues.h. Supporting a new type requires only adding one specialization there; no changes tofillWithValueare needed.Stores the typed fill value in
FieldMeta["_FillValue"](the NetCDF/CF standard attribute name), making it available to IO and metadata queries.Calls
Kokkos::deep_copy(InDataArray, scalar)to broadcast the scalar to every element of the view. This works for host views (HostArray*), device views (Array*), and all dimensionalities (1D–5D) without additional dispatch.Only the view being attached is filled; no assumption is made about whether the other memory space (host or device) has been attached.
Ordering invariants preserved by this design:
Field::create()is always called beforeattachData(), so the field exists inAllFieldsbefore fill time.The
"_FillValue"metadata entry is set atattachData()time, which precedes any IO write operation in the initialization workflow.Restart reads happen after
attachData()and overwrite fill entries with data from the restart file.Compute routines run after initialization and overwrite active entries, leaving inactive entries (below
MaxLayerCell) at the fill value.
4.2.2 No per-module manual initialization required
Because attachData() handles initialization automatically, no module needs to
call an explicit fill routine. Modules may still call Kokkos::deep_copy or
similar to set specific values, but this is independent of fill-value management.
4.2.3 Ordering invariant and anti-patterns
Invariant: attachData() with FillOnAttach = true (the default) must always
be called before any real values are written to the array. Any data written before
such a call is silently overwritten by the auto-fill step and is dead code.
Three anti-patterns discovered and corrected during implementation:
Class A — Wasted pre-fill: A deepCopy (or equivalent write) is applied to an
array before defineFields() calls attachData() on it. Because attachData()
overwrites the entire array, the earlier write has no effect.
// WRONG: initial value is overwritten by attachData()
deepCopy(SpecVol, 1.0_Real / RhoSw);
defineFields(); // calls attachData(), fills SpecVol with FillValueReal
Class B — Compute-time re-initialization: A deepCopy(arr, 0) (or any scalar)
at the top of a recurring compute method resets the entire array on every timestep,
overwriting the correct fill values in inactive layers with zero.
// WRONG: destroys fill values in inactive layers every timestep
void Foo::compute(...) {
deepCopy(LocArr, 0);
parallelFor({NCellsAll}, KOKKOS_LAMBDA(I4 ICell) {
// writes only active layers KMin..KMax
});
}
The correct pattern for Classes A and B is to do nothing: attachData() sets the
fill value once at initialization, and compute kernels that iterate only over active
layers naturally leave inactive layers at the fill value.
Class C — Time-level pointer update: attachData() is called each timestep to
re-point the IO field to the newly-current time-level array (e.g., in
OceanState::updateTimeLevels() and Tracers::updateTimeLevels()). The array
already contains the just-computed state. Calling attachData() with the default
FillOnAttach = true here would silently destroy the computed values before IO
writes them. This is the most dangerous anti-pattern because the model runs to
completion without aborting, but all output fields contain fill values.
// WRONG: destroys computed state before IO can write it
void OceanState::updateTimeLevels() {
CurTimeIndex = (CurTimeIndex + 1) % NTimeLevels;
Field::attachFieldData<Array2DReal>(NormalVelocityFldName,
NormalVelocity[CurTimeIndex]); // fills!
}
// CORRECT: pointer update only, computed values preserved
void OceanState::updateTimeLevels() {
CurTimeIndex = (CurTimeIndex + 1) % NTimeLevels;
Field::attachFieldData<Array2DReal>(NormalVelocityFldName,
NormalVelocity[CurTimeIndex], false);
}
A related instance occurs in the IO write path when the time-coordinate array is
populated before attachFieldData("time", OutTime) is called. The correct pattern
is to attach first (which fills the single-element array with the fill value), then
assign the elapsed time to OutTime(0). Because Kokkos host views share backing
memory, the assignment is visible through the stored pointer before the field is
written to the file.
4.3 Module-level changes: removing FillValue from Field::create() calls
The FillValue parameter is removed from Field::create(). All ~50 call sites
across Omega drop the fill value argument. The pattern is:
// BEFORE — explicit fill value argument
Field::create(FieldName, Description, Units, StdName,
ValidMin, ValidMax, FillValueReal, NDims, DimNames);
// AFTER — fill value is deduced automatically from the array type at attachData()
Field::create(FieldName, Description, Units, StdName,
ValidMin, ValidMax, NDims, DimNames);
The Tracers::define() helper in src/ocn/Tracers.h / Tracers.cpp and
TracerDefs.inc also have their FillValue parameter and arguments removed,
since Tracers::define() calls Field::create() internally.
4.4 Boundary edge and vertex handling
During implementation, all compute routines that populate fields defined on mesh edges or vertices must be audited to confirm that every valid boundary entry — including edges and vertices adjacent to land cells and edges at the base of the water column — receives an explicit computed value rather than relying on zero-initialization. Where such reliance is found, an explicit assignment must be added. The CTest in Section 5.5 detects regressions after the change.
4.5 Edge flux field masking
Edge fields that represent fluxes normal to the edge face — such as
NormalVelocityTend, VelocityDel2Aux.Del2Edge, and NormalVelocity itself —
must carry zeros (not fill values) in the boundary layer sub-ranges
[MinLayerEdgeTop, MinLayerEdgeBot) and (MaxLayerEdgeTop, MaxLayerEdgeBot],
where one neighboring cell is active but the other is not. Two helper methods
are added to VertCoord (components/omega/src/ocn/VertCoord.h/.cpp):
zeroEdgeField(Array2DReal &Arr, I4 NEdgesAll) — zeros all layers in
[MinLayerEdgeTop, MaxLayerEdgeBot]. Used before recomputing a flux-type edge
field each time step so that boundary edges show 0, not FillValueReal. Layers
outside the valid range retain their fill value from attachData(). Called for:
NormalVelocityTendat the start ofTendencies::computeVelocityTendenciesOnly()VelocityDel2Aux.Del2Edgebefore theedgeAuxState2kernel inAuxiliaryState::computeMomAux(), because the Del4 hyperdiffusion kernel accumulatesDel2Edgeover all edges sharing a cell or vertex; fill values in boundary layers of a neighbouring edge would corruptDel2DivCellandDel2RelVortVertex.
applyEdgeLayerMask(Array2DReal &Arr, I4 NEdgesAll) — enforces the full
three-zone mask on an edge field after IC or restart read: layers outside
[MinLayerEdgeTop, MaxLayerEdgeBot] are set to FillValueReal; layers inside
[MinLayerEdgeTop, MaxLayerEdgeBot] but outside the active range
[MinLayerEdgeBot, MaxLayerEdgeTop] are set to 0; active layers are left
unchanged.
applyCellLayerMask(Array2DReal &Arr, I4 NCellsAll) — enforces the
two-zone mask on a cell field after IC or restart read: layers outside the
active range [MinLayerCell, MaxLayerCell] are set to FillValueReal; active
layers are left unchanged. Cell fields have no boundary (zero) zone because a
cell column is either active or inactive at a given layer — there is no
neighbor-dependent partial-activity range as there is for edges and vertices.
applyVertexLayerMask(Array2DReal &Arr, I4 NVerticesAll) — enforces the
two-zone mask on a vertex field: layers outside [MinLayerVertexTop, MaxLayerVertexBot] are set to FillValueReal; active layers are left
unchanged. Unlike an edge, a vertex does not have a zeroed boundary zone: a
boundary vertex with one or more active surrounding cells holds valid, generally
non-zero data (for example, relative vorticity at such a vertex is computed from
its active surrounding edges — see VorticityAuxVars::computeVarsOnVertex, which
loops over the full [MinLayerVertexTop, MaxLayerVertexBot] range). Zeroing that
boundary band would discard real signal, so the whole valid range is kept. There
is currently no vertex-based state field read from IC/restart, so this method has
no production caller yet; it is provided for completeness and is exercised by the
CTest (Section 5.6).
All three apply*LayerMask methods use the inclusive [Min, Max] layer
convention consistent with computeGeomZHeight/computePressure. They are
driven through OceanState::applyLayerMasks(TimeLevel)
(src/ocn/OceanState.cpp), which is called from ocnInit (OceanInit.cpp)
after exchangeHalo and before copyToHost. applyLayerMasks applies the
edge mask to NormalVelocity and the cell mask to PseudoThickness,
Temperature, and Salinity — the state and tracer fields read from
IC/restart.
Fields that are not fluxes — MeanPseudoThickEdge, FluxPseudoThickEdge —
are genuinely undefined at boundary edges (interpolating thickness when one
neighboring cell is land has no physical meaning) and therefore correctly retain
FillValueReal in those layers; no zeroing is applied to them.
VorticityAux on edges is already computed over the full valid range
[MinLayerEdgeTop, MaxLayerEdgeBot], so it requires no additional masking.
HighOrderFlxHorz in TracerHorzAdvOnCell — the 3-D working array
(NTracers, NEdgesSize, NVertLayers) that accumulates horizontal tracer flux
contributions per edge requires separate treatment. The edge-pass overload of
TracerHorzAdvOnCell::operator() writes HighOrderFlxHorz(L, IEdge, K) only
for layers K ∈ [0, MaxLayerEdgeTop(IEdge)]; layers beyond that index retain
their fill values from attachData(). The cell-pass overload then reads
HighOrderFlxHorz over the cell’s own active range [MinLayerCell, MaxLayerCell],
which for cells adjacent to shallower edges extends past MaxLayerEdgeTop of
those edges — reading fill values and corrupting active-layer tracer tendencies.
The fix is to add MinLayerEdgeBot and MaxLayerEdgeTop as members of
TracerHorzAdvOnCell and clamp the cell-pass inner loop to
[max(KStart, MinLayerEdgeBot(IEdge)), min(KEnd, MaxLayerEdgeTop(IEdge)+1)).
This follows the identical pattern already used by PseudoThicknessFluxDivOnCell,
TracerDiffOnCell, TracerAuxVars::computeVarsOnCells, and every other
cell-level flux-accumulation kernel, and avoids the need to zero
HighOrderFlxHorz before the edge pass each timestep.
5 Verification and Testing
A test test/ocn/FillValueTest.cpp is added and registered as FILL_VALUE_TEST
with 8 MPI tasks (building testFillValue.exe) using add_omega_test() in the
test CMakeLists.txt. The test performs a full ocean initialization (matching
the StateTest pattern: MachEnv, logging, Config, TimeStepper, IO, Field,
IOStream, Decomp, Halo, HorzMesh, VertCoord, Tracers,
AuxiliaryState, PressureGrad, Tendencies, OceanState), reads the initial
state, exchanges halos, and applies applyEdgeLayerMask to NormalVelocity
before running its assertions. Each test counts mismatches and, on any failure,
accumulates an Error(ErrorCode::Fail, ...) into a top-level Error whose
final state sets the process return code.
5.1 Test: fill constant values
Verify that each Omega fill value constant exactly equals its NetCDF-C
counterpart (FillValueI4 == NC_FILL_INT, FillValueI8 == NC_FILL_INT64,
FillValueR4 == NC_FILL_FLOAT, FillValueR8 == NC_FILL_DOUBLE), comparing
directly against the <netcdf.h> NC_FILL_* macros.
Tests requirements: 2.1, 2.2, 2.7
5.2 Test: attachData auto-fill
Create a Field, allocate a Kokkos host array whose elements are all set to a
distinct sentinel (0), then call attachData(). Verify that after the call
every element of the array equals FillValueR8.
Tests requirements: 2.3, 2.7
5.3 Test: inactive layers contain fill values
After VertCoord initialization, verify that the cell field GeomZMid carries
FillValueReal in every inactive layer (k >= MaxLayerCell(ICell)) for all
owned cells. This confirms that the auto-fill applied at attachData() time
survives initialization for inactive layers.
Tests requirements: 2.5, 2.7
5.4 Test: NormalVelocity three-zone fill/zero/real pattern
After full state initialization, IC read, halo exchange, and
VertCoord::applyEdgeLayerMask(), verify that NormalVelocity satisfies the
three-zone invariant for every owned edge:
Fully inactive layers (outside
[MinLayerEdgeTop, MaxLayerEdgeBot]): equalFillValueReal.Boundary layers (inside
[MinLayerEdgeTop, MaxLayerEdgeBot]but outside[MinLayerEdgeBot, MaxLayerEdgeTop]): equal0.Active layers (
[MinLayerEdgeBot, MaxLayerEdgeTop]): not equal toFillValueReal(for a quiescent IC the value is0, which is valid).
This guards against two regressions: (a) the original zero-initialization
assumption (boundary layers silently filled with zero before attachData()
was changed) and (b) IC files that store zeros in inactive layers, which
applyEdgeLayerMask must replace with FillValueReal.
Tests requirements: 2.5, 2.6, 2.7
5.5 Test: applyCellLayerMask two-zone pattern
Allocate a synthetic cell field, fill it with a sentinel value (≠ 0 and
≠ FillValueReal), apply VertCoord::applyCellLayerMask(), and verify for every
owned cell that:
Inactive layers (outside
[MinLayerCell, MaxLayerCell]): equalFillValueReal.Active layers (
[MinLayerCell, MaxLayerCell]): retain the sentinel value.
Using a sentinel rather than IC data gives an exact check that the mask overwrites only the inactive layers.
Tests requirements: 2.5, 2.7
5.6 Test: applyVertexLayerMask two-zone pattern
Allocate a synthetic vertex field, fill it with the same sentinel value, apply
VertCoord::applyVertexLayerMask(), and verify for every owned vertex that:
Inactive layers (outside
[MinLayerVertexTop, MaxLayerVertexBot]): equalFillValueReal.Active layers (
[MinLayerVertexTop, MaxLayerVertexBot]): retain the sentinel value.
Vertices have no zeroed boundary zone (a boundary vertex holds valid data), so
the whole valid range is checked for the sentinel. Because no vertex-based state
field is read from IC/restart, this synthetic test is the primary coverage for
applyVertexLayerMask.
Tests requirements: 2.5, 2.7