-
Notifications
You must be signed in to change notification settings - Fork 186
Add particlefile_to_v3_zarr() helper #2811
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5f8ddde
0a87552
c039fca
06f1d58
f8e4f0a
19c03e3
d51b592
6bc7636
d9215ed
b0118bc
7dadfdb
8f5fc06
4d12c93
8a609a9
d28259e
117bcde
6dd4a1b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
|
VeckoTheGecko marked this conversation as resolved.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was trying to implement the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added a new test example in 117bcde and it seems to work properly
Could you provide me with a failing example? |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import os | ||
| import warnings | ||
| from pathlib import Path | ||
| from typing import TYPE_CHECKING, Any | ||
|
|
||
| import polars as pl | ||
| import pyarrow.parquet as pq | ||
| import xarray as xr | ||
|
|
||
| if TYPE_CHECKING: | ||
| import io | ||
| from pathlib import Path | ||
|
|
||
|
|
||
| def _decode_dict_to_utf8(d: dict[Any, Any]) -> dict[Any, Any]: | ||
| ret = {} | ||
| for key, item in d.items(): | ||
| if isinstance(key, bytes): | ||
| key = key.decode("utf8") | ||
| if isinstance(item, dict): | ||
| item = _decode_dict_to_utf8(item) | ||
| if isinstance(item, bytes): | ||
| item = item.decode("utf8") | ||
| ret[key] = item | ||
| return ret | ||
|
|
||
|
|
||
| def particlefile_to_v3_zarr(from_parquet: str | Path | io.BytesIO, to_zarr: str | os.PathLike) -> None: | ||
| """Convert a v4 particle file (parquet) to v3-style zarr output. | ||
|
|
||
| Reads the parquet file, renames columns to v3 conventions | ||
| (``particle_id`` -> ``trajectory``, ``t`` -> ``time``, ``x`` -> ``lon``, | ||
| ``y`` -> ``lat``), and reshapes the data into a 2D | ||
| ``(trajectory, obs)`` zarr store. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| from_parquet : Path | ||
| Path to the input parquet file. | ||
| to_zarr : Path | ||
| Path to the output zarr store. Must have a ``.zarr`` suffix. | ||
|
|
||
| Raises | ||
| ------ | ||
| ValueError | ||
| If ``to_zarr`` does not have a ``.zarr`` suffix. | ||
|
|
||
| Notes | ||
| ----- | ||
| This is not a lazy operation — the entire parquet file is read into memory | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would a relatively simple improvement be to do the loading/pivoting/writing on a per-variable basis? So that only one variable is kept in memory at a time? Or is this not worth the extra code/effort for now?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think let's leave for a future PR. I'll make an issue to track EDIT: #2818 |
||
| and pivoted before writing to zarr. For large particle files this may | ||
| require significant memory. Performance improvements are welcome via PRs. | ||
| """ | ||
|
Comment on lines
+50
to
+55
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I think that this sort of functionality (i.e., pivoting dataframes to zarr CF convention output) can live outside of Parcels since its also relevant to other tools (e..g, TRACMASS). @oj-tooth have you guys done any work around pivoting (larger than in-memory) tabular data into Lagrangian Zarr datasets?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe its something that we can collab on across-teams |
||
| to_zarr = Path(to_zarr) | ||
| if to_zarr.suffix != ".zarr": | ||
| raise ValueError(f"Parameter `to_zarr` must have a '.zarr' suffix. Got {to_zarr=}.") | ||
| df = pl.read_parquet(from_parquet) | ||
| table = pq.read_table(from_parquet) | ||
|
|
||
| # TODO: Check for available memory here and fail as a safeguard? | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is intentionally left here. I think it would be good to discuss (or leave for a future PR) |
||
|
|
||
| # Rename columns to v3 conventions | ||
| rename_map = {"particle_id": "trajectory", "t": "time", "x": "lon", "y": "lat"} | ||
| try: | ||
| df = df.rename(rename_map) | ||
| except pl.exceptions.ColumnNotFoundError as e: | ||
| e.add_note( | ||
| f"Expected to have all columns {list(rename_map)} in the output parquet. Got columns {list(df.columns)}." | ||
| ) | ||
| raise e | ||
|
|
||
| metadata_per_field = {} | ||
| for parquet_var in table.schema.names: | ||
| zarr_var = rename_map.get(parquet_var, parquet_var) # default to parquet name | ||
| metadata_per_field[zarr_var] = table.field(parquet_var).metadata or {} | ||
|
|
||
| # Group by trajectory, sort by time, and assign observation index | ||
| df = df.sort("trajectory", "time") | ||
| df = df.with_columns( | ||
| pl.col("time").cum_count().over("trajectory").alias("obs") - 1, | ||
| ) | ||
|
|
||
| # Pivot to (trajectory, obs) dimensions | ||
| trajectories = df["trajectory"].unique().sort() | ||
| data_vars = [c for c in df.columns if c not in ("trajectory", "obs")] | ||
|
|
||
| ds_dict = {} | ||
| for var in data_vars: | ||
| pivoted = df.pivot(on="obs", index="trajectory", values=var, sort_columns=True) | ||
| value_cols = [c for c in pivoted.columns if c != "trajectory"] | ||
| ds_dict[var] = ( | ||
| ["trajectory", "obs"], | ||
| pivoted.select(value_cols).to_numpy(), | ||
| _decode_dict_to_utf8(metadata_per_field[var]), | ||
| ) | ||
|
|
||
| ds = xr.Dataset( | ||
| ds_dict, | ||
| coords={"trajectory": trajectories.to_numpy()}, | ||
|
VeckoTheGecko marked this conversation as resolved.
|
||
| attrs=_decode_dict_to_utf8(table.schema.metadata), | ||
| ) | ||
| ds = ds.assign_coords({"obs": ds["obs"]}) | ||
|
|
||
| with warnings.catch_warnings(): | ||
| warnings.filterwarnings( | ||
| "ignore", message="Consolidated metadata is currently not part in the Zarr format 3 specification." | ||
| ) | ||
| ds.to_zarr(to_zarr) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import io | ||
|
|
||
| import hypothesis.strategies as st | ||
| import numpy as np | ||
| import pandas as pd | ||
| import pyarrow as pa | ||
| import pyarrow.parquet as pq | ||
|
|
||
| from parcels._core.particle import ParticleClass | ||
| from parcels._core.particlefile import get_schema | ||
|
|
||
| from .particle import particle_class | ||
| from .time import time_interval as st_time_interval | ||
|
|
||
| __all__ = [ | ||
| "particlefile_output", | ||
| ] | ||
|
|
||
|
|
||
| def _generate_dummy_data(particle: ParticleClass, nparticles=10, nobs=10) -> pd.DataFrame: | ||
| """Build a pandas dataframe from a particleclass. | ||
|
|
||
| Only variables with ``to_write=True`` are included. | ||
| """ | ||
| columns: dict[str, np.ndarray] = {} | ||
| variables = {var.name: var for var in particle.variables if var.to_write} | ||
| try: | ||
| particle_id = variables["particle_id"] | ||
| t = variables["t"] | ||
| except KeyError as e: | ||
| e.add_note("This function requires 'particle_id' and 't' to be set") | ||
|
|
||
| nobs_total = nparticles * nobs | ||
| columns = {} | ||
| columns["particle_id"] = np.repeat( | ||
| np.arange(0, nparticles, dtype=particle_id.dtype).reshape((-1, 1)), | ||
| nobs, | ||
| axis=1, | ||
| ).flatten() | ||
| columns["t"] = np.repeat( | ||
| np.linspace(0, nparticles * 3, num=nparticles, dtype=t.dtype).reshape((-1, 1)), | ||
| nobs, | ||
| axis=1, | ||
| ).flatten() | ||
|
|
||
| data_vars = set(variables.keys()) - {"particle_id", "t"} | ||
|
|
||
| for name in data_vars: | ||
| var = variables[name] | ||
| columns[name] = np.linspace(0, 10000, num=nobs_total, dtype=var.dtype) | ||
|
|
||
| return pd.DataFrame(columns) | ||
|
|
||
|
|
||
| @st.composite | ||
| def particlefile_output(draw, nobs=None, nparticles=None) -> io.BytesIO: | ||
| particle = draw(particle_class()) | ||
| time_interval = draw(st_time_interval()) | ||
| if nobs is None: | ||
| nobs = draw(st.integers(min_value=5, max_value=100)) | ||
| if nparticles is None: | ||
| nparticles = draw(st.integers(min_value=5, max_value=100)) | ||
|
|
||
| df = _generate_dummy_data(particle, nparticles, nobs) | ||
| schema = get_schema(particle, {}, time_interval) | ||
| buf = io.BytesIO() | ||
| pq.write_table( | ||
| pa.table(df, schema=schema), | ||
| buf, | ||
| ) | ||
| return buf |
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I thought that this would be useful for the future as well |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| """Provides Hypothesis strategies for generating Variable, ParticleClass, and related particle data.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import numpy as np | ||
| from hypothesis import strategies as st | ||
|
|
||
| from parcels._core.particle import Particle, Variable, get_default_particle | ||
|
|
||
| __all__ = ["particle_class", "variable", "variable_name"] | ||
|
|
||
| # Valid numpy dtypes for Variable | ||
| _VARIABLE_DTYPES = [np.float32, np.float64, np.int32, np.int64, np.bool_] | ||
|
|
||
| variable_dtype = st.sampled_from(_VARIABLE_DTYPES).map(np.dtype) | ||
|
|
||
| # Names used by the default Particle — generated variables must not collide with these | ||
| _DEFAULT_PARTICLE_NAMES = {var.name for var in Particle.variables} | ||
|
|
||
| # Python identifiers that are not keywords (required by _assert_str_and_python_varname) | ||
| variable_name = ( | ||
| st.from_regex(r"[a-z][a-z0-9_]{0,15}", fullmatch=True) | ||
| .filter(lambda s: s.isidentifier()) | ||
| .filter(lambda s: not __import__("keyword").iskeyword(s)) | ||
| .filter(lambda s: s not in _DEFAULT_PARTICLE_NAMES) | ||
| ) | ||
|
|
||
|
|
||
| @st.composite | ||
| def variable(draw, name=None, dtype=None, to_write=None): | ||
| """Strategy for generating Variable instances. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| name : str, optional | ||
| Fixed variable name. If None, generates a valid Python identifier. | ||
| dtype : numpy.dtype, optional | ||
| Fixed dtype. If None, draws from common numpy dtypes. | ||
| to_write : bool, optional | ||
| Fixed to_write value. If None, draws True or False. | ||
| """ | ||
| if name is None: | ||
| name = draw(variable_name) | ||
| if dtype is None: | ||
| dtype = draw(variable_dtype) | ||
| if to_write is None: | ||
| to_write = draw(st.booleans()) | ||
|
|
||
| if to_write: | ||
| attrs = draw( | ||
| st.just({}) | ||
| | st.dictionaries( | ||
| keys=st.text(min_size=1, max_size=10, alphabet="abcdefghijklmnopqrstuvwxyz_"), | ||
| values=st.text(min_size=1, max_size=20), | ||
| max_size=3, | ||
| ) | ||
| ) | ||
| else: | ||
| attrs = {} | ||
|
|
||
| return Variable(name=name, dtype=dtype, initial=0, to_write=to_write, attrs=attrs) | ||
|
|
||
|
|
||
| @st.composite | ||
| def particle_class(draw, min_vars=0, max_vars=5, spatial_dtype=None): | ||
| """Strategy that extends the default Particle with additional variables. | ||
|
|
||
| This mirrors the predominant use case in Parcels: starting from | ||
| ``get_default_particle`` and adding custom variables via ``add_variable``. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| min_vars : int | ||
| Minimum number of extra variables to add. | ||
| max_vars : int | ||
| Maximum number of extra variables to add. | ||
| spatial_dtype : type, optional | ||
| np.float32 or np.float64 for the base particle. If None, draws one. | ||
| """ | ||
| if spatial_dtype is None: | ||
| spatial_dtype = draw(st.sampled_from([np.float32, np.float64])) | ||
|
|
||
| base = get_default_particle(spatial_dtype) | ||
|
|
||
| n = draw(st.integers(min_value=min_vars, max_value=max_vars)) | ||
| if n == 0: | ||
| return base | ||
|
|
||
| names = draw(st.lists(variable_name, min_size=n, max_size=n, unique=True)) | ||
| extra_vars = [draw(variable(name=name)) for name in names] | ||
| return base.add_variable(extra_vars) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| from tests import mark, utils | ||
|
|
||
| __all__ = ["mark", "utils"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import pytest | ||
|
|
||
| zarr_filterwarning_consolidated_metadata = pytest.mark.filterwarnings( | ||
| "ignore:Consolidated metadata is currently not part in the Zarr format 3 specification" | ||
| ) |

Uh oh!
There was an error while loading. Please reload this page.