01 · From an array to a scientific cube¶
Context¶
A raster workflow has returned a three-dimensional array of observed daily maximum temperatures. The values are real, but the array alone does not retain which axis represents time, latitude, or longitude.
Question¶
How can we reconstruct a self-describing cube and verify it as a map, a site history, and an interactive space-time object?
Analysis story¶
We will deliberately separate values from metadata, rebuild their scientific context, and send the result through one minimal plotting pipe.
Data used in this lesson¶
Every value comes from the PRISM Group at Oregon State University's AN91d daily 4 km climate product. This repository carries a small Boulder-region extract for 1–30 January 2024 so the lesson runs offline without replacing observations with generated values. The data validation page records source URLs, terms, checksums, bounds, units, and acceptance tests.
Prepare · Recover coordinates and provenance for the array¶
from pathlib import Path
import xarray as xr
# Find the repository from either a root-level documentation build or a kernel
# started beside this notebook, then open the checksum-controlled PRISM extract.
data_path = next(
candidate / "tests" / "fixtures" / "real_data" / "prism_boulder_january_2024.nc"
for candidate in (Path.cwd(), *Path.cwd().parents)
if (candidate / "tests" / "fixtures" / "real_data" / "prism_boulder_january_2024.nc").exists()
)
prism = xr.open_dataset(data_path, engine="scipy").load()
# These assertions are part of the teaching contract: official source,
# canonical cube dimensions, complete daily time, and declared Celsius units.
assert prism.attrs["source"] == "PRISM Group, Oregon State University"
assert prism.attrs["is_synthetic"] == 0
assert prism.sizes == {"time": 30, "y": 24, "x": 24}
assert prism["tmax"].attrs["units"] == "degC"
import numpy as np
# Use an inspectable 18-day, 5-row × 6-column portion of the official grid.
source = prism["tmax"].isel(time=slice(0, 18), y=slice(8, 13), x=slice(8, 14))
values = source.values
# Reattach the coordinate and provenance fields that an anonymous NumPy array
# cannot carry. No temperatures are generated or altered in this conversion.
cube = xr.DataArray(
values,
dims=("time", "y", "x"),
coords={name: source[name] for name in ("time", "y", "x")},
name="tmax",
attrs=dict(source.attrs),
)
cube.attrs.update(source=prism.attrs["source"], is_synthetic=0)
np.testing.assert_array_equal(cube.values, source.values)
assert cube.dims == ("time", "y", "x")
cube
<xarray.DataArray 'tmax' (time: 18, y: 5, x: 6)> Size: 2kB
array([[[ 4.8030e+00, 4.8280e+00, 5.0380e+00, 5.2370e+00,
5.2860e+00, 6.3090e+00],
[ 4.1470e+00, 4.2130e+00, 4.5960e+00, 4.9420e+00,
5.1020e+00, 5.7290e+00],
[ 3.9310e+00, 4.2350e+00, 4.5030e+00, 4.8520e+00,
5.1460e+00, 5.3150e+00],
[ 4.1380e+00, 4.5560e+00, 4.7000e+00, 4.8910e+00,
5.0250e+00, 5.1920e+00],
[ 4.1330e+00, 4.6050e+00, 4.6240e+00, 4.6410e+00,
4.7780e+00, 4.8990e+00]],
[[ 8.0530e+00, 8.8100e+00, 1.0048e+01, 1.2164e+01,
1.3193e+01, 1.3904e+01],
[ 6.7530e+00, 7.8350e+00, 9.3410e+00, 1.2069e+01,
1.2862e+01, 1.3380e+01],
[ 6.3880e+00, 8.2640e+00, 9.8290e+00, 1.1732e+01,
1.2674e+01, 1.3002e+01],
[ 7.6230e+00, 9.8930e+00, 1.0926e+01, 1.1994e+01,
1.2775e+01, 1.2936e+01],
[ 7.7840e+00, 1.0157e+01, 1.0902e+01, 1.1557e+01,
...
4.9810e+00, 5.4130e+00],
[-7.3500e-01, 2.1100e-01, 1.6550e+00, 4.1260e+00,
4.9670e+00, 5.1430e+00],
[-1.5910e+00, 6.5700e-01, 2.3440e+00, 3.7800e+00,
4.6520e+00, 4.8250e+00],
[-5.0600e-01, 1.3860e+00, 2.6800e+00, 4.0800e+00,
4.8380e+00, 4.8590e+00],
[-4.3800e-01, 1.5290e+00, 2.8340e+00, 4.1090e+00,
5.3480e+00, 5.2340e+00]],
[[ 4.6540e+00, 5.7080e+00, 7.1570e+00, 8.7850e+00,
8.0160e+00, 8.5740e+00],
[ 3.3900e+00, 4.8540e+00, 6.5250e+00, 7.6970e+00,
7.6020e+00, 7.9000e+00],
[ 3.0190e+00, 5.5660e+00, 7.6070e+00, 7.7340e+00,
7.3870e+00, 7.4210e+00],
[ 4.8300e+00, 7.6270e+00, 8.6530e+00, 7.7700e+00,
7.3110e+00, 7.3500e+00],
[ 5.1180e+00, 7.9380e+00, 8.6990e+00, 8.3160e+00,
8.1080e+00, 7.9490e+00]]], dtype=float32)
Coordinates:
* time (time) datetime64[ns] 144B 2024-01-01 2024-01-02 ... 2024-01-18
* y (y) float64 40B 40.17 40.12 40.08 40.04 40.0
* x (x) float64 48B -105.4 -105.4 -105.3 -105.3 -105.2 -105.2
Attributes:
long_name: daily maximum air temperature
standard_name: air_temperature
units: degC
source_variable: PRISM tmax
is_synthetic: 0
source: PRISM Group, Oregon State UniversityFigure 1 · Read the cube in familiar views¶
Compare a map from the cold outbreak with the history of one grid cell. Both views must retain the observed PRISM values.
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 2, figsize=(10, 3.6), constrained_layout=True)
cube.isel(time=15).plot(ax=axes[0], cmap="magma", cbar_kwargs={"label": "°C"})
axes[0].set_title("PRISM maximum temperature · 16 January")
cube.isel(y=2, x=3).plot(ax=axes[1], marker="o", color="#2f6f6d")
axes[1].set_title("One PRISM grid cell through time")
axes[1].set_ylabel("Daily maximum temperature (°C)")
plt.show()
Pipe · Inspect the same evidence as a cube¶
The method remains one short sentence. The viewer is generated from the same
validated DataArray; it does not become a second data authority.
from html import escape
from IPython.display import HTML
from cubedynamics import pipe, verbs as v
viewer = (
pipe(cube)
| v.plot(
title="PRISM daily maximum temperature · Boulder region",
cmap="magma",
thin_time_factor=1,
)
).unwrap()
# Isolate the complete viewer document from the surrounding MkDocs page.
viewer_srcdoc = escape(viewer.to_html(), quote=True)
HTML(
f'''<iframe
title="Interactive PRISM maximum-temperature cube"
srcdoc="{viewer_srcdoc}"
style="width: 100%; height: 760px; border: 1px solid #b8c5c2; border-radius: 4px;"
sandbox="allow-scripts"
loading="lazy"
></iframe>'''
)
Preparing cube… 00%
Preparing cube… 05%
Preparing cube… 11%
Preparing cube… 16%
Preparing cube… 22%
Preparing cube… 27%
Preparing cube… 33%
Preparing cube… 38%
Preparing cube… 44%
Preparing cube… 50%
Preparing cube… 55%
Preparing cube… 61%
Preparing cube… 66%
Preparing cube… 72%
Preparing cube… 77%
Preparing cube… 83%
Preparing cube… 88%
Preparing cube… 94%
Preparing cube… 100%
Preparing cube… 100%
What the figure tells us¶
The mid-January cold outbreak is visible through depth and across the map. The validation suite independently decodes all six HTML textures and checks them against these source indices, including the declared back-face reversal.
Try the next variation¶
Choose a different observed time window or spatial subset. The reconstruction and pipe stay unchanged.
Data used¶
| Field | Frozen analysis input |
|---|---|
| Provider | PRISM Group, Oregon State University |
| Product | AN91d daily 4 km time series |
| Dates | 2024-01-01 to 2024-01-30 |
| Fixture | tests/fixtures/real_data/prism_boulder_january_2024.nc |
| Provenance record | tests/fixtures/real_data/prism_boulder_january_2024.provenance.json |
The PRISM source reference describes current catalog support; the fixture record above identifies the observations used here. Data validation documents checksums and acceptance checks. The analytical baseline and thresholds belong to this story, not the provider.
Reproduce¶
Clone the repository, then run these commands from its root:
python -m pip install -e ".[vignettes]"
python scripts/run_vignettes.py docs/vignettes/cube_from_arrays.ipynb
No network is needed after installation. Open the downloaded notebook in Jupyter and run all cells to see the same figures. The website executes these cells during its strict build. Environment setup and the vignette contract explain the workflow.