Skip to content

Add flat table output formats: CSV and Parquet - #7

Open
lispandfound wants to merge 2 commits into
borehole-gridfrom
csv-format
Open

lispandfound wants to merge 2 commits into
borehole-gridfrom
csv-format

Conversation

@lispandfound

@lispandfound lispandfound commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

The borehole grid in #5 labels its columns with coordinates on the i axis,
but nothing carried those labels into a form a person or a spreadsheet reads.
Pulling a profile out of the Zarr output means opening a DataTree and knowing
which i a station sits at.

Flat table output

Writing to a *.csv, *.parquet or *.pq path now emits one row per grid
point, under a header that names every column. Any coordinate a grid carries
past the (i, j, k) index becomes a leading label column, so a borehole run
comes out as

grid,site,network,i,j,k,x,y,z,depth,rho,vp,vs,qp,qs,alpha
boreholes,GULL,NZ,0,0,0,1531509.5,5161095.5,-641.124146,0,1810,1800.00012,500,...

which pandas groups straight back into per-station profiles. A grid column
separates the refinement levels of an SW4 domain in a single table.

Both encodings come off one flatten call in nzcvm/formats/table.py, so the
columns are identical either way:

  • CSV writes nine digits of precision, which round-trips a float32 exactly
    and keeps a seven-digit easting from printing as 1.5315095e+06.
  • Parquet keeps the float32 columns typed, so there is no text rendering
    at all. pandas[parquet] is already a dependency, because the borehole grid
    in Add a borehole grid: vertical profiles at a set of sites #5 reads its sites from Parquet.

Per-column metadata makes a small Parquet file the larger of the two (10082
vs 9628 bytes for the four-site example), so the choice is about the reader
rather than about size. Both writers hold the table in memory, which suits the
outputs a person reads rather than volumetric grids.

Layers have to preserve coordinates

The label columns rest on an assumption the Layer contract never states:
that a layer hands back the coordinates of the grid it received. A layer that
assembles its output from raw NumPy does not, because the array has no
coordinates to keep — nzcvm.layers.dummy.constant is exactly that, so the
assumption is not idle, and map_blocks rejects a chunk whose coordinates
differ from its template. Only five of the six layers appear in
examples/borehole.toml, so backus had never seen a labelled grid.

tests/test_layer_coordinates.py passes a two-site borehole grid to each
registered layer on its own, then to the whole chain, then through
execute_model_pipeline. All six preserve the labels and the grid shape. The
covered set is read off the config modules in nzcvm.config.layers rather
than Layer.registry, which the rest of the suite adds dummy and sentinel
layers to, so a new shipped layer fails there until someone covers it.

Testing

just synthetic
uv run nzcvm generate examples/borehole.toml boreholes.csv
uv run nzcvm generate examples/borehole.toml boreholes.parquet
uv run --dev pytest tests/          # 274 passed, 1 skipped
uv run --dev pytest --doctest-modules nzcvm/
uv run ruff check nzcvm/ tests/ && uv run ty check && uv run deptry .

Verified against the real pipeline: both tables reproduce the Zarr output of
the same run element for element, and grouping by site gives the same four
profiles as #5. Reverting one layer to drop its coordinates fails both the
per-layer test and the map_blocks test, so those tests bite.

What survives, and what doesn't

Probed each stage of a run for a grid carrying an extra coordinate:

Stage Extra coordinate Extra data variable
GridSchema.new(...) constructor rejected rejected
assign_coords after construction kept n/a
each of the six layers, and the chain kept n/a
execute_model_pipeline / map_blocks kept n/a
QualitiesSchema.from_dataset kept n/a
Zarr, NetCDF, CSV and Parquet writers kept n/a
GridSchema.from_dataset read back kept rejected

A coordinate survives because xarray keeps it on each data variable it
indexes, so cls.new(**dataset.data_vars) reassembles it for free.

Unrelated observation

BackusAveragedLayer emits RuntimeWarning: divide by zero encountered in reciprocal whenever a sample falls outside every mesh, because it takes
np.reciprocal(q.vs) before masking on covered. The masked result is finite
and correct. It reproduces on a regular grid too, so it predates this stack
and I left it alone.


Stack created with GitHub Stacks CLIGive Feedback 💬

@lispandfound
lispandfound added this pull request to stack #6 September 10, 2026 09:39
@github-actions

Copy link
Copy Markdown

Benchmark for 78ef729

Click to view benchmark
Test Base PR %
Mesh_Point_Query/centre/4096000 247.6±0.87ns 248.1±0.98ns +0.20%
Mesh_Point_Query/far_corner/4096000 177.1±1.63ns 177.1±1.72ns 0.00%
Mesh_Point_Query/near_origin/4096000 240.2±1.07ns 240.4±1.25ns +0.08%

@github-actions

Copy link
Copy Markdown

Benchmark for cc7c656

Click to view benchmark
Test Base PR %
Mesh_Point_Query/centre/4096000 311.4±2.82ns 304.0±4.29ns -2.38%
Mesh_Point_Query/far_corner/4096000 202.1±4.32ns 206.3±5.73ns +2.08%
Mesh_Point_Query/near_origin/4096000 348.9±3.03ns 357.0±3.73ns +2.32%

@lispandfound lispandfound changed the title Add a CSV output format labelled by grid and site Add flat table output formats: CSV and Parquet Sep 10, 2026
@github-actions

Copy link
Copy Markdown

Benchmark for 09d7c3e

Click to view benchmark
Test Base PR %
Mesh_Point_Query/centre/4096000 234.5±1.86ns 236.3±2.09ns +0.77%
Mesh_Point_Query/far_corner/4096000 227.6±3.36ns 226.8±4.18ns -0.35%
Mesh_Point_Query/near_origin/4096000 224.7±1.84ns 225.6±3.82ns +0.40%

lispandfound and others added 2 commits September 10, 2026 22:35
A borehole grid labels its columns with coordinates on the `i` axis, but
nothing carried those labels into a form a person or a spreadsheet reads.
Pulling a profile out of the Zarr output means opening a DataTree and knowing
which `i` a station sits at.

Writing to a `*.csv`, `*.parquet` or `*.pq` path now emits one row per grid
point, under a header that names every column. Any coordinate a grid carries
past the `(i, j, k)` index becomes a leading label column, so a borehole run
comes out as

    grid,site,network,i,j,k,x,y,z,depth,rho,vp,vs,qp,qs,alpha
    boreholes,GULL,NZ,0,0,0,1531509.5,5161095.5,-641.124146,0,1810,1800,500,...

which pandas groups straight back into per-station profiles. A `grid` column
separates the refinement levels of an SW4 domain in a single table.

Both encodings come off one `flatten` call, so the columns are identical
either way. CSV writes nine digits of precision, which round-trips a float32
exactly and keeps a seven-digit easting from printing as `1.5315095e+06`.
Parquet keeps the float32 columns typed, so there is no text rendering at all;
`pandas[parquet]` is already a dependency, because the borehole grid reads its
sites from Parquet. Per-column metadata makes a small Parquet file the larger
of the two, so the choice is about the reader rather than about size.

Both writers flatten through pandas and so hold the table in memory: they are
for the outputs a person reads, not for volumetric grids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The label columns rest on an assumption the `Layer` contract never states:
that a layer hands back the coordinates of the grid it received. A layer that
assembles its output from raw NumPy does not, because the array has no
coordinates to keep. `nzcvm.layers.dummy.constant` is exactly that, so the
assumption is not idle, and `map_blocks` rejects a chunk whose coordinates
differ from its template.

Only five of the six layers appear in `examples/borehole.toml`, so `backus`
had never seen a labelled grid at all.

`tests/test_layer_coordinates.py` passes a two-site borehole grid to each
registered layer on its own, then to the whole chain, then through
`execute_model_pipeline`, and checks the labels come back on the result with
the grid's shape intact. All six pass. Reverting one layer to drop its
coordinates fails both the per-layer test and the `map_blocks` test, so the
tests bite.

The covered set is read off the config modules in `nzcvm.config.layers`
rather than `Layer.registry`, which the rest of the suite adds dummy and
sentinel layers to. A new shipped layer therefore fails here until someone
covers it.

The layers only accept concrete chunks, since `execute_model_pipeline` hoists
the chunked dispatch into one `map_blocks` per grid, so the fixtures compute
the grid before handing it over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Benchmark for 938db35

Click to view benchmark
Test Base PR %
Mesh_Point_Query/centre/4096000 257.5±10.82ns 257.9±11.84ns +0.16%
Mesh_Point_Query/far_corner/4096000 185.5±8.13ns 183.8±7.84ns -0.92%
Mesh_Point_Query/near_origin/4096000 250.1±11.91ns 252.0±12.95ns +0.76%

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant