Skip to content

Commit 5c9e337

Browse files
FEAT: Add fieldset.describe() (#2729)
* Add reprs to base classes ScalarInterpolator and VectorInterpolator * Add tabulate dependency * Add pandas-stubs * Add fieldset.describe * Fix mypy errors * Self-review * Add constant_field to example * Update header * Rename to "Context" * Add mesh and tiem interval info * Make "name" the first column * Update column name * Add xfail * Add _mesh to BaseGrid * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent fba7b4f commit 5c9e337

7 files changed

Lines changed: 143 additions & 1 deletion

File tree

.github/ci/recipe.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ requirements:
4646
- holoviews >= 1.22.0 # https://github.com/prefix-dev/rattler-build/issues/2326
4747
- pooch >=1.8.0
4848
- polars >=1.31.0
49+
- tabulate >=0.10.0
4950

5051
tests:
5152
- python:

pixi.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ cf_xarray = ">=0.8.6"
3535
cftime = ">=1.6.3"
3636
pooch = ">=1.8.0"
3737
polars = ">=1.31.0"
38+
tabulate = ">=0.10.0"
39+
3840

3941
[dependencies]
4042
parcels = { path = "." }
@@ -155,6 +157,7 @@ numpydoc-lint = { cmd = "python tools/numpydoc-public-api.py", description = "Li
155157
mypy = "*"
156158
lxml = "*" # in CI
157159
types-tqdm = "*"
160+
pandas-stubs = "*"
158161

159162
[feature.typing.tasks]
160163
typing = { cmd = "mypy src/parcels --install-types", description = "Run static type checking with mypy." }
@@ -177,3 +180,6 @@ test-notebooks = { features = ["test", "notebooks"], solve-group = "main" }
177180
docs = { features = ["docs", "notebooks"], solve-group = "docs" }
178181
typing = { features = ["typing"], solve-group = "main" }
179182
build = { features = ["rattler-build"] }
183+
184+
[pypi-dependencies]
185+
detect-test-pollution = ">=1.2.0, <2"

src/parcels/_core/basegrid.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import numpy as np
1010

11+
import parcels._typing as ptyping
1112
from parcels._core.spatialhash import SpatialHash
1213

1314
if TYPE_CHECKING:
@@ -25,6 +26,7 @@ class BaseGrid(ABC):
2526
"""Base class for parcels.XGrid and parcels.UxGrid defining common methods and properties"""
2627

2728
_spatialhash: SpatialHash | None
29+
_mesh: ptyping.Mesh
2830

2931
@abstractmethod
3032
def search(self, z: float, y: float, x: float, ei=None) -> dict[str, tuple[int, float | np.ndarray]]:

src/parcels/_core/fieldset.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from parcels._core.utils.time import get_datetime_type_calendar
2222
from parcels._core.utils.time import is_compatible as datetime_is_compatible
2323
from parcels._python import NOTSET, NotSetType
24+
from parcels._reprs import fieldset_describe
2425
from parcels.interpolators import (
2526
XConstantField,
2627
)
@@ -284,6 +285,10 @@ def from_sgrid_conventions(
284285
model = StructuredModelData.from_sgrid_conventions(ds, mesh, vector_fields)
285286
return cls([model])
286287

288+
def describe(self):
289+
"""Return a table description of a FieldSet, which fields it has and their interpolation methods."""
290+
return fieldset_describe(self)
291+
287292

288293
def assert_compatible_fieldsets(left: FieldSet, right: FieldSet) -> None:
289294
"""Assert that two FieldSets can be combined without name conflicts.

src/parcels/_reprs.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,19 @@
33
from __future__ import annotations
44

55
import textwrap
6-
from typing import TYPE_CHECKING, Any, cast
6+
from dataclasses import dataclass
7+
from typing import TYPE_CHECKING, Any, Literal, cast
78

89
import numpy as np
910
import xarray as xr
1011

12+
from parcels._python import isinstance_noimport
13+
1114
if TYPE_CHECKING:
1215
from parcels import Field, FieldSet, ParticleSet
1316
from parcels._core.field import VectorField
17+
from parcels._core.model import ModelData
18+
from parcels._core.utils.time import TimeInterval
1419

1520

1621
def fieldset_repr(fieldset: FieldSet) -> str:
@@ -177,3 +182,81 @@ def _format_list_items_multiline(items: list[str] | dict, level: int = 1, with_b
177182

178183
def is_builtin_object(obj):
179184
return obj.__class__.__module__ == "builtins"
185+
186+
187+
@dataclass
188+
class _FieldSetDescriptionRow:
189+
type_: Literal["Field", "VectorField", "Context"]
190+
model_id: int | None
191+
name: str
192+
interp_method_or_value: str
193+
194+
def to_dict(self) -> dict[str, str]:
195+
return {
196+
"Name": self.name,
197+
"Type": self.type_,
198+
"Grid number": str(self.model_id) if self.model_id is not None else "-",
199+
"Interp method / value": self.interp_method_or_value,
200+
}
201+
202+
203+
def _print_table(rows: list[_FieldSetDescriptionRow]) -> str:
204+
import pandas as pd
205+
206+
dicts = [r.to_dict() for r in rows]
207+
return pd.DataFrame(dicts).sort_values(["Grid number", "Type", "Name"]).to_markdown(index=False)
208+
209+
210+
def _print_time_interval(time_interval: TimeInterval | None) -> str:
211+
if time_interval is None:
212+
return repr(time_interval)
213+
return repr((time_interval.left, time_interval.right))
214+
215+
216+
def fieldset_describe(fieldset: FieldSet) -> str:
217+
rows: list[_FieldSetDescriptionRow] = []
218+
models: dict[int, int] = {} # mapping of memory ID to a human readable ID
219+
220+
assert fieldset._fields is not None
221+
222+
for field in fieldset._fields.values():
223+
model_id: int
224+
225+
# Set human readable model ID
226+
parent_id = id(_get_parent_model(field))
227+
models[parent_id] = models.get(parent_id, len(models))
228+
model_id = models[parent_id]
229+
230+
type_ = cast(Literal["Field", "VectorField", "Context"], field.__class__.__name__)
231+
232+
rows.append(
233+
_FieldSetDescriptionRow(
234+
type_=type_,
235+
model_id=model_id,
236+
name=field.name,
237+
interp_method_or_value=repr(field.interp_method),
238+
)
239+
)
240+
for k, v in fieldset.context.items():
241+
rows.append(
242+
_FieldSetDescriptionRow(
243+
type_="Context",
244+
model_id=None,
245+
name=k,
246+
interp_method_or_value=repr(v),
247+
)
248+
)
249+
return (
250+
_print_table(rows)
251+
+ f"""\
252+
253+
254+
mesh: {fieldset.models[0].grid._mesh}
255+
time interval: {_print_time_interval(fieldset.time_interval)}"""
256+
)
257+
258+
259+
def _get_parent_model(field: Field | VectorField) -> ModelData:
260+
if isinstance_noimport(field, "Field"):
261+
return field.model # type:ignore[union-attr]
262+
return field.U.model # type:ignore[union-attr]

src/parcels/interpolators/_base.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@ class ScalarInterpolator(ABC):
77
def interp(self, particle_positions, grid_positions, field) -> Any: #! API a WIP
88
...
99

10+
def __repr__(self):
11+
return f"{self.__class__.__name__}(...)"
12+
1013

1114
class VectorInterpolator(ABC):
1215
@abstractmethod
1316
def interp(self, particle_positions, grid_positions, vectorfield) -> Any: #! API a WIP
1417
...
18+
19+
def __repr__(self):
20+
return f"{self.__class__.__name__}(...)"

tests/test_fieldset.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,21 @@
1818
ds = datasets_structured["ds_2d_left"]
1919

2020

21+
@pytest.fixture
22+
def fieldset_two_models():
23+
ds1 = datasets_structured["ds_2d_left"][["U_A_grid", "V_A_grid", "grid"]].rename({"U_A_grid": "U", "V_A_grid": "V"})
24+
ds2 = datasets_structured["ds_2d_left"][["U_A_grid", "V_A_grid", "grid"]].rename(
25+
{"U_A_grid": "U_wind", "V_A_grid": "V_wind"}
26+
)
27+
28+
fset1 = FieldSet.from_sgrid_conventions(ds1, mesh="flat")
29+
fset2 = FieldSet.from_sgrid_conventions(ds2, mesh="flat", vector_fields={"UV_wind": ("U_wind", "V_wind")})
30+
fset2.add_context("my_value", 2.0)
31+
fset2.add_context("my_list", [1, 2, "hello"])
32+
fset2.add_constant_field("constant_field", 3.0)
33+
return fset1 + fset2
34+
35+
2136
def test_fieldset_init_wrong_types():
2237
with pytest.raises(ValueError, match="Expected `model` to be a ModelData object. Got .*"):
2338
FieldSet([1.0, 2.0, 3.0])
@@ -373,3 +388,27 @@ def test_fieldset_add_context_values():
373388

374389
assert fset.context["c1"] == 1.0
375390
assert fset.context["c2"] == 2.0
391+
392+
393+
@pytest.mark.xfail(
394+
reason="There's test pollution occuring between test_fieldKh_Brownian and this test due to how constant fields are handled. We should remove this global state."
395+
)
396+
def test_fieldset_describe(fieldset_two_models: FieldSet):
397+
fieldset = fieldset_two_models
398+
expected = """\
399+
| Name | Type | Grid number | Interp method / value |
400+
|:---------------|:------------|:--------------|:------------------------|
401+
| my_list | Context | - | [1, 2, 'hello'] |
402+
| my_value | Context | - | 2.0 |
403+
| U | Field | 0 | XLinear(...) |
404+
| V | Field | 0 | XLinear(...) |
405+
| UV | VectorField | 0 | XLinear_Velocity(...) |
406+
| U_wind | Field | 1 | XLinear(...) |
407+
| V_wind | Field | 1 | XLinear(...) |
408+
| UV_wind | VectorField | 1 | XLinear_Velocity(...) |
409+
| constant_field | Field | 2 | XConstantField(...) |
410+
411+
mesh: flat
412+
time interval: (np.datetime64('2000-01-01T00:00:00.000000000'), np.datetime64('2001-01-01T00:00:00.000000000'))"""
413+
actual = fieldset.describe()
414+
assert actual == expected

0 commit comments

Comments
 (0)