Dimension Mapping and Data Model#
A MARS request defines which data to retrieve from FDB. Each keyword
with more than one value defines an axis and must be mapped to a
Zarr dimension via AxisDefinition.
Keywords with a single value may also be mapped — useful when MARS
restricts a keyword to one value but you still want it as an explicit
dimension in the resulting array.
From MARS Keywords to Zarr Dimensions#
Each AxisDefinition passed to
add_part() becomes exactly one
dimension in the resulting Zarr array.
The position of each
AxisDefinitionin the list determines its dimension index in the array.An implicit final dimension always contains the grid points (decoded field values).
One-to-One Mapping#
In the simplest case, each MARS keyword maps to its own Zarr dimension.
[
AxisDefinition(["date"], Chunking.SINGLE_VALUE), # Dim 0
AxisDefinition(["time"], Chunking.SINGLE_VALUE), # Dim 1
AxisDefinition(["param"], Chunking.SINGLE_VALUE), # Dim 2
]
Given date=2020-01-01/to/2020-01-03, time=0/6/12/18, and
param=165/166/167, the resulting array has shape (3, 4, 3, N)
where N is the number of grid points.
Many-to-One Mapping#
Multiple MARS keywords can be flattened into a single Zarr dimension.
A common use case is merging date and time into a unified
datetime axis.
[
AxisDefinition(["date", "time"], Chunking.SINGLE_VALUE), # Dim 0
AxisDefinition(["param"], Chunking.SINGLE_VALUE), # Dim 1
]
The dimension size equals the product of the number of values of
each keyword. With date having 3 values and time having 4:
Dimension size = 3 × 4 = 12
The rightmost key varies fastest (row-major order, like C and
NumPy defaults). In ["date", "time"], time cycles through all
its values before date advances:
Index: 0 1 2 3 4 5 6 7 8 9 10 11
date: d0 d0 d0 d0 d1 d1 d1 d1 d2 d2 d2 d2
time: t0 t1 t2 t3 t0 t1 t2 t3 t0 t1 t2 t3
index = time + date × num_times
Important
The order of keys matters. With ["time", "date"], date
becomes the fastest-varying keyword instead of time.
Axis Mapping Visualized#
graph LR
subgraph MARS["MARS Request Keywords"]
date["date (3 values)"]
time["time (4 values)"]
param["param (3 values)"]
step["step (1 value)"]
end
subgraph AD["AxisDefinitions"]
ad0["AxisDefinition 0<br> keys=['date', 'time']"]
ad1["AxisDefinition 1<br> keys=['param']"]
ad2["AxisDefinition 2<br> keys=['step']"]
end
subgraph Zarr["Zarr Array Dimensions"]
dim0["Dim 0: datetime<br>size = 3 x 4 = 12"]
dim1["Dim 1: param<br>size = 3"]
dim2["Dim 2: step<br>size = 1"]
dim3["Dim 3: grid points<br>(implicit)"]
end
date --> ad0
time --> ad0
param --> ad1
step --> ad2
ad0 --> dim0
ad1 --> dim1
ad2 --> dim2
Coordinate System#
A ChunkedDataView exposes an (N+1)-dimensional integer index space:
Axes 0 … N−1 — one per
AxisDefinition.Axis N — the implicit trailing dimension holding the decoded grid-point float32 values for each field.
The array shape is:
(axis0_size, axis1_size, …, axisN-1_size, num_values)
All axis indices are zero-based. Axis sizes are determined by the total
number of distinct values held across all parts (see
Combining Multiple MARS Requests below).
Chunking#
Chunking determines how many values along
a dimension are grouped into a single Zarr chunk:
Chunking mode |
Behaviour |
Chunk size along axis |
|---|---|---|
Each value along the axis is its own chunk |
1 |
|
The entire axis is stored in a single chunk |
Full axis length |
|
|
Groups every |
|
For example, with date having 4 values and param having 3
values:
[
AxisDefinition(["date"], Chunking.NONE), # chunk size = 4
AxisDefinition(["param"], Chunking.SINGLE_VALUE), # chunk size = 1
]
# Array shape: (4, 3, N)
# Chunk shape: (4, 1, N)
[
AxisDefinition(["date"], Chunking.IndividualChunk(chunkShape=2)), # chunk size = 2
AxisDefinition(["param"], Chunking.SINGLE_VALUE), # chunk size = 1
]
# Array shape: (4, 3, N)
# Chunk shape: (2, 1, N) ← two dates per chunk, four chunks total
IndividualChunk is useful when
neither extreme fits — for instance, when you want to batch a temporal
axis into multi-day windows for efficient I/O while still keeping chunks
small enough to fit in memory.
A chunk is addressed by a chunk-index tuple (c0, c1, …, cN-1) —
one integer per MARS axis. The implicit values dimension is never
chunked. Each chunk index ci maps to an axis range:
Chunking |
Axis range covered by chunk index |
|---|---|
|
|
|
|
|
|
The chunk’s bounding box is the Cartesian product of these per-axis ranges. Its flat memory footprint is:
chunkSize0 × chunkSize1 × … × chunkSizeN-1 × num_values float32 values
Memory Considerations#
Each chunk access loads the entire chunk into memory. With
SINGLE_VALUE each chunk contains
one set of grid-point values, keeping memory usage small.
With NONE the chunk spans the
full axis, and when multiple axes use NONE the chunk sizes compound.
For example, consider a grid with 1 million points (N = 1_000_000)
and three axes all set to NONE:
[
AxisDefinition(["date"], Chunking.NONE), # 30 values
AxisDefinition(["time"], Chunking.NONE), # 4 values
AxisDefinition(["param"], Chunking.NONE), # 10 values
]
# Chunk shape: (30, 4, 10, 1_000_000)
# Chunk size: 30 × 4 × 10 × 1_000_000 × 4 bytes = ~4.5 GB
Accessing any element in this array loads the single 4.5 GB chunk.
Switching to SINGLE_VALUE on all
three axes reduces each chunk to a single field
(1 × 1 × 1 × 1_000_000 × 4 bytes ≈ 4 MB).
Warning
Using NONE on multiple axes
can cause unexpectedly large memory allocations. Start with
SINGLE_VALUE on all axes and
only switch individual axes to NONE when you know you always
consume them in full.
Choosing a chunking strategy
Mode |
When to use |
|---|---|
|
Default choice. Minimises memory per access; ideal when you read individual time steps or parameters one at a time. |
|
Use when you always read the full axis in one go and want to
reduce the number of FDB round-trips (e.g. a small |
|
Use when you need a middle ground — for example, batching a
365-day date axis into weekly ( |
Fill Value#
When a chunk is accessed and some of its fields are absent from FDB,
the missing slots are filled with a sentinel value. The default is
float('nan').
To override it, call
fill_value()(or useChunkedDataViewBuilderdirectly):
from pychunked_data_view import ChunkedDataViewBuilder, AxisDefinition, Chunking, ExtractorType
builder = ChunkedDataViewBuilder(fdb_config_file=None)
builder.add_part({...}, [...], ExtractorType.GRIB)
builder.fill_value(-999.0) # use -999.0 instead of NaN
view = builder.build()
print(view.fillValue()) # -999.0
Combining Multiple MARS Requests#
Call add_part() multiple times to
combine data from different MARS requests into a single Zarr array.
Use extend_on_axis() to specify which
dimension grows when parts are joined. All other dimensions must have
the same number of values across parts.
builder = SimpleStoreBuilder()
# Part 1: surface parameters
# Dimension D is count date x time
# Dimension P1 is count param
# Dimension N is the number of values in the grid
# Resulting shape of this part is [D, P1, N]
builder.add_part(
{
"levtype": "sfc",
"param": [165, 166],
...
},
[
AxisDefinition(["date", "time"], Chunking.SINGLE_VALUE),
AxisDefinition(["param"], Chunking.SINGLE_VALUE),
],
ExtractorType.GRIB,
)
# Part 2: pressure level parameters
# Dimension D is count date x time
# Dimension P2 is count param x levelist
# Dimension N is the number of values in the grid
# Resulting shape of this part is [D, P2, N]
builder.add_part(
{
"levtype": "pl",
"param": [131, 132],
"levelist": [50, 100],
...
},
[
AxisDefinition(["date", "time"], Chunking.SINGLE_VALUE),
AxisDefinition(["param", "levelist"], Chunking.SINGLE_VALUE),
],
ExtractorType.GRIB,
)
# Extend on the param dimension (index 1)
# Final shape will be [D, P1 + P2, N]
builder.extend_on_axis(1)
store = builder.build()
The datetime dimension (index 0) must have the same values in both
parts. The param dimension (index 1) grows: 2 surface parameters +
4 pressure-level combinations (2 params × 2 levels) = 6 entries total.
Each part occupies a rectangular sub-region of the global index space
described by a closed bounding box — one [lower_i, upper_i] interval
per axis (both bounds inclusive). Parts tile the extension axis
without overlap; their bounding boxes are identical on every other axis.
Global index space after extend_on_axis(1):
Axis 0 (date×time) varies along rows (0–3).
Axis 1 (param) varies along columns (0–5).
0 1 2 3 4 5
┌───┬───┬───┬───┬───┬───┐
0 │ A │ A │ B │ B │ B │ B │
├───┼───┼───┼───┼───┼───┤
1 │ A │ A │ B │ B │ B │ B │
├───┼───┼───┼───┼───┼───┤
2 │ A │ A │ B │ B │ B │ B │
├───┼───┼───┼───┼───┼───┤
3 │ A │ A │ B │ B │ B │ B │
└───┴───┴───┴───┴───┴───┘
└─ A ──┘└───── B ──────┘
Part A (sfc, 2 params) bounding box: axis0=[0,3], axis1=[0,1]
Part B (pl, 4 params) bounding box: axis0=[0,3], axis1=[2,5]
See also
Technical Insights — how the library resolves a chunk access into FDB sub-requests and writes each field into the correct buffer slot.