Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,29 @@ for n, site in enumerate(grid.site.values):
print(site, vs[n].values)
```

Writing to a `*.csv` or `*.parquet` path instead puts those labels in the
header, one row per sample:

```sh
uv run nzcvm generate examples/borehole.toml boreholes.csv
```

```
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,100,50,1
boreholes,GULL,NZ,0,0,1,1531509.5,5161095.5,-616.124146,25,1810,1800,500,100,50,1
```

which `pandas` groups straight back into profiles:

```python
import pandas as pd

table = pd.read_csv("boreholes.csv") # or read_parquet("boreholes.parquet")
for site, profile in table.groupby("site", sort=False):
print(site, profile.vs.to_numpy())
```

`examples/borehole.toml` runs four profiles over the `just synthetic` dataset,
two of them inside a basin.

Expand Down Expand Up @@ -252,6 +275,13 @@ Inferred from the output path, or forced with `--format`.
| `netcdf` | `*.h5` | NetCDF4/HDF5 via xarray |
| `sfile` | `*.sfile` | sfile HDF5 format for driving [SW4](github.com/geodynamics/sw4) |
| `emod3d` | directory | `rho3dfile.d`, `vp3dfile.p`, `vs3dfile.s` binaries suitable for driving [EMOD3D](https://doi.org/10.1785/BSSA0860041091) |
| `csv` | `*.csv` | Flat table, one row per point, labelled by grid, and by site |
| `parquet` | `*.parquet`, `*.pq` | The same table, with the float32 columns kept typed |

`csv` and `parquet` share one flattening step, so the columns are the same
either way. Both hold the whole table in memory, which suits the outputs a
person reads: boreholes, transects, a few profiles. Volumetric grids belong in
Zarr or NetCDF.

### Example configuration

Expand Down Expand Up @@ -596,7 +626,8 @@ grid = grid.assign_coords(site=("i", ["GULL", "TERR"]))
```

The name must avoid `RESERVED_COORDINATES`, since a coordinate shadows a
variable or attribute of the same name.
variable or attribute of the same name. The `csv` and `parquet` writers turn
any coordinate outside the contract into a label column.

Here is a transect: a line of vertical columns between two
points, shaped `(n, 1, nk)`.
Expand Down
17 changes: 15 additions & 2 deletions nzcvm/formats/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from nzcvm.velocity_model import VelocityModel

from . import datatree, emod3d, sfile
from . import datatree, emod3d, sfile, table


class Format(StrEnum):
Expand All @@ -30,6 +30,8 @@ class Format(StrEnum):
SFILE = auto()
NETCDF = auto()
ZARR = auto()
CSV = auto()
PARQUET = auto()


def from_path(path: Path) -> Format:
Expand Down Expand Up @@ -59,7 +61,14 @@ def from_path(path: Path) -> Format:
>>> from_path(Path("model.h5"))
<Format.NETCDF: 'netcdf'>
"""
format_map = {".sfile": Format.SFILE, ".h5": Format.NETCDF, ".zarr": Format.ZARR}
format_map = {
".sfile": Format.SFILE,
".h5": Format.NETCDF,
".zarr": Format.ZARR,
".csv": Format.CSV,
".parquet": Format.PARQUET,
".pq": Format.PARQUET,
}
ext = path.suffix

if ext in format_map:
Expand Down Expand Up @@ -106,3 +115,7 @@ def write_velocity_model(
datatree.to_netcdf(velocity_model, path, quantise_arrays)
case Format.ZARR:
datatree.to_zarr(velocity_model, path)
case Format.CSV:
table.to_csv(velocity_model, path)
case Format.PARQUET:
table.to_parquet(velocity_model, path)
126 changes: 126 additions & 0 deletions nzcvm/formats/table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Flat table velocity-model writers, for CSV and Parquet.

Both write one row per grid point. Each row gives the logical index, the
position, and then the components. A grid may hold coordinates beyond the
``(i, j, k)`` index, and each of those becomes a leading label column. That
puts a borehole grid's ``site`` labels in the table, so a reader can tell one
profile from another without counting rows.

:func:`flatten` builds the table and the two writers encode it, so the column
set is the same either way. Parquet keeps the float32 arrays typed and exact
and compresses a large table well, while CSV renders every number as text,
which is what :data:`FLOAT_FORMAT` is for. Per-column metadata makes a small
Parquet file the larger of the two, so the choice is about the reader rather
than about size.

The whole table goes through :mod:`pandas`, which keeps every point in memory
at once. That suits the outputs a person reads: boreholes, transects, a few
profiles. Volumetric grids belong in Zarr or NetCDF.
"""

from pathlib import Path

import pandas as pd

from nzcvm.components import Component
from nzcvm.coordinates import Coordinate
from nzcvm.grids.grid import Grid
from nzcvm.qualities import Qualities
from nzcvm.velocity_model import VelocityModel

#: CSV precision. Every grid and quality array is float32, and nine digits
#: round-trip a float32 exactly. Left to pandas, a seven-digit easting comes
#: out as ``1.5315095e+06`` instead. Parquet keeps the float32 typed and
#: needs none of this.
FLOAT_FORMAT = "%.9g"

#: Column for the name of the grid a row came from. An SW4 domain writes one
#: grid per refinement level, so the name is what separates them in one table.
GRID_COLUMN = "grid"

#: Columns every grid has, in the order they appear after the label columns.
FIXED_COLUMNS: tuple[str, ...] = (
Coordinate.I,
Coordinate.J,
Coordinate.K,
Coordinate.X,
Coordinate.Y,
Coordinate.Z,
Coordinate.DEPTH,
*Component,
)


def _table(name: str, grid: Grid, qualities: Qualities) -> pd.DataFrame:
"""Flatten one grid and its qualities into a row-per-point table.

Parameters
----------
name :
Name of the grid, written into the :data:`GRID_COLUMN` column.
grid :
Grid holding the ``x``, ``y``, ``z`` and ``depth`` positions.
qualities :
Components sampled on *grid*.

Returns
-------
pandas.DataFrame
One row per grid point, columns ordered
:data:`GRID_COLUMN`, labels, then :data:`FIXED_COLUMNS`.
"""
table = grid.assign(qualities).to_dataframe().reset_index()
# Any coordinate past the logical index labels the rows: `site` on a
# borehole grid, and anything a custom grid builder adds.
labels = [column for column in table.columns if column not in FIXED_COLUMNS]
table.insert(0, GRID_COLUMN, name)
return table[[GRID_COLUMN, *labels, *FIXED_COLUMNS]]


def flatten(velocity_model: VelocityModel) -> pd.DataFrame:
"""Flatten every grid in *velocity_model* into one row-per-point table.

Parameters
----------
velocity_model :
Model whose grids the query pipeline has already populated.

Returns
-------
pandas.DataFrame
The grids concatenated in order, one row per point.
"""
tables = [
_table(name, grid, qualities)
for name, (grid, qualities) in velocity_model.pairwise.items()
]
return pd.concat(tables, ignore_index=True)


def to_csv(velocity_model: VelocityModel, path: Path) -> None:
"""Write *velocity_model* to *path* as one CSV table.

Parameters
----------
velocity_model :
Model whose grids the query pipeline has already populated.
path :
Destination file.
"""
flatten(velocity_model).to_csv(path, index=False, float_format=FLOAT_FORMAT)


def to_parquet(velocity_model: VelocityModel, path: Path) -> None:
"""Write *velocity_model* to *path* as one Parquet table.

The same columns as :func:`to_csv`, with the float32 arrays kept typed
rather than rendered as text.

Parameters
----------
velocity_model :
Model whose grids the query pipeline has already populated.
path :
Destination file.
"""
flatten(velocity_model).to_parquet(path, index=False)
Loading
Loading