Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
- name: Setup pixi
uses: prefix-dev/setup-pixi@v0.9.6
with:
pixi-version: v0.69.0
pixi-version: v0.72.0
environments: dev
activate-environment: true

Expand All @@ -50,7 +50,7 @@ jobs:
- name: Setup pixi
uses: prefix-dev/setup-pixi@v0.9.6
with:
pixi-version: v0.69.0
pixi-version: v0.72.0
environments: dev
activate-environment: true

Expand Down Expand Up @@ -90,7 +90,7 @@ jobs:
- name: Setup pixi
uses: prefix-dev/setup-pixi@v0.9.6
with:
pixi-version: v0.69.0
pixi-version: v0.72.0
environments: test${{ matrix.python }}
activate-environment: true

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
- uses: actions/checkout@v4
- uses: prefix-dev/setup-pixi@v0.9.6
with:
pixi-version: v0.69.0
pixi-version: v0.72.0
environments: docs
activate-environment: true
- run: pixi run build-docs
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/extended-win.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ jobs:
uses: actions/checkout@v4

- name: Setup pixi
uses: prefix-dev/setup-pixi@v0.9.4
uses: prefix-dev/setup-pixi@v0.9.6
with:
pixi-version: v0.69.0
pixi-version: v0.72.0
environments: ${{ matrix.env }}

- name: Install extended modflow6 executables
Expand Down
24 changes: 2 additions & 22 deletions docs/profile/chunked_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
streaming benefit: the codec writer's array2chunks iterates dask blocks one
layer at a time, never materializing the full array simultaneously.

Also profiles text-format load to confirm dask wrapping adds zero overhead.

Variants
--------
**Write (user-constructed arrays):**
Expand All @@ -16,7 +14,6 @@

**Load from text file:**
npf load (eager) Package.load(path, dims)
npf load (chunked) Package.load(path, dims, chunks="auto")

**xarray views:**
npf to_xarray (numpy) npf.to_xarray() on numpy-backed package
Expand Down Expand Up @@ -127,9 +124,9 @@ def main():
)
)

# ── Load from text (confirms zero dask overhead) ──────────────────────────
# ── Load from text ──────────────────────────────────────────────────────
print(f"\n{'─' * 60}")
print("Load from text file (Lark parser dominates — confirms zero dask overhead)")
print("Load from text file")
print(f"{'─' * 60}")

# Write a text file to load from
Expand All @@ -147,18 +144,6 @@ def main():
)
)

timing_results.append(
report(
"npf load (chunked)",
time_reads(
lambda: Npf.load(npf_path, dims=dims, chunks="auto"),
N,
"load chunked",
include_slow,
),
)
)

sections.append({"name": "chunked-profile", "results": timing_results})

# ── Memory profiling ──────────────────────────────────────────────────────
Expand All @@ -182,11 +167,6 @@ def main():
mem_results.append(
profile_memory(lambda: Npf.load(npf_path, dims=dims), "npf load (eager)")
)
mem_results.append(
profile_memory(
lambda: Npf.load(npf_path, dims=dims, chunks="auto"), "npf load (chunked)"
)
)
sections.append({"name": "chunked-profile-memory", "results": mem_results})

if args.output:
Expand Down
31 changes: 23 additions & 8 deletions flopy4/mf6/_types.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
"""Shared type definitions for flopy4.mf6 packages."""

from pathlib import Path
from typing import TypeAlias, Union
from typing import Protocol, TypeAlias, TypeVar

from numpy.typing import NDArray
import numpy as np

try:
import dask.array as da
_DT = TypeVar("_DT", bound=np.generic, covariant=True)

ArrayLike: TypeAlias = Union[NDArray, da.Array]
"""NDArray or dask Array — used for griddata and READARRAY period fields."""
except ImportError:
ArrayLike: TypeAlias = NDArray # type: ignore[misc, no-redef]

class _ArrayLike(Protocol[_DT]):
"""Structural stand-in for "ndarray or duck array of this dtype".

Matches anything exposing `.dtype`/`.shape` like `numpy.ndarray` does —
`dask.array.Array`, and other duck arrays (sparse, cupy, xarray-backed,
...) — without enumerating specific libraries or importing them here.
Used for griddata and READARRAY period fields, which may be dask-backed
(see `codec/writer/filters.py`'s `array2chunks`, which streams
dask-backed arrays without materializing them).
"""

@property
def dtype(self) -> np.dtype[_DT]: ...
@property
def shape(self) -> tuple[int, ...]: ...


IntArrayLike: TypeAlias = _ArrayLike[np.int64]
FloatArrayLike: TypeAlias = _ArrayLike[np.float64]


def _optional_path(v):
Expand Down
2 changes: 1 addition & 1 deletion flopy4/mf6/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ def has_stress_period_data(self):

try:
for f in _attrs.fields(type(self._package)):
if f.metadata.get("dfn_block") == "period":
if f.metadata.get("block") == "period":
attr_name = f.alias if (f.alias and f.name.startswith("_")) else f.name
if getattr(self._package, attr_name, None) is not None:
return True
Expand Down
13 changes: 7 additions & 6 deletions flopy4/mf6/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@

from flopy4.mf6.constants import MF6
from flopy4.mf6.dimensions import DimensionResolverMixin
from flopy4.mf6.spec import field, fields_dict
from flopy4.mf6.spec import fields_dict
from flopy4.mf6.spec import xattree_field as field
from flopy4.mf6.utils.grid import update_maxbound
from flopy4.mf6.write_context import WriteContext
from flopy4.uio import IO, Loader, Writer
Expand Down Expand Up @@ -80,11 +81,11 @@ def _update_maxbound_if_needed(self):
(like CHD, DRN, etc.) will have it automatically computed at
initialization and updated when period arrays change.
"""
# Codegen v2 packages compute maxbound in Package.__attrs_post_init__
# via _init_period_dtype; skip the xattree metadata scan.
from flopy4.mf6.converter.egress.unstructure import has_dfn_metadata
# Package leaves compute maxbound in Package.__attrs_post_init__ via
# _init_period_dtype; skip the xattree metadata scan for them.
from flopy4.mf6.package import Package

if has_dfn_metadata(type(self)):
if isinstance(self, Package):
return

# Check if component has a maxbound field and period block arrays
Expand Down Expand Up @@ -233,7 +234,7 @@ def _collect_child_griddata_datasets(self) -> dict:
_fields = _attrs.fields(type(child))
except _attrs.exceptions.NotAnAttrsClassError:
continue
if not any(f.metadata.get("dfn_block") == "griddata" for f in _fields):
if not any(f.metadata.get("block") == "griddata" for f in _fields):
continue
try:
ds = child.to_xarray()
Expand Down
2 changes: 1 addition & 1 deletion flopy4/mf6/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from flopy4.mf6.component import Component
from flopy4.mf6.constants import MF6
from flopy4.mf6.spec import field
from flopy4.mf6.spec import xattree_field as field
from flopy4.utils import to_path


Expand Down
37 changes: 12 additions & 25 deletions flopy4/mf6/converter/egress/unstructure.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,8 @@
from flopy4.mf6.component import Component
from flopy4.mf6.constants import FILL_DNODATA
from flopy4.mf6.context import Context
from flopy4.mf6.spec import FileInOut, block_sort_key, blocks_dict


def has_dfn_metadata(cls: type) -> bool:
"""True if cls is a codegen v2 package (has attrs fields with 'dfn_block' metadata).

Old xattree-based packages use 'block' as the metadata key; codegen v2 uses 'dfn_block'.
This distinguishes Ic/Chd/Npf (codegen v2) from Dis/Gwf/Package (xattree).
"""
if not attrs.has(cls):
return False
return any("dfn_block" in f.metadata for f in attrs.fields(cls))
from flopy4.mf6.package import Package
from flopy4.mf6.spec import FileInOut, block_sort_key, blocks_dict, to_field_type


def _path_to_tuple(name: str, value: Path, inout: FileInOut) -> tuple[str, ...]:
Expand Down Expand Up @@ -159,8 +149,8 @@ def _normalize_kper(kper: Any) -> int | None:
return None


def _unstructure_codegen_v2(value: Any) -> dict[str, Any]:
"""Unstructure a codegen v2 (attrs-based, non-xattree) Package."""
def _unstructure_package(value: Package) -> dict[str, Any]:
"""Unstructure a package (attrs-based, non-xattree)."""
cls = type(value)
blocks: dict[str, dict[str, Any]] = {}
# Block names that must appear in output even when empty (e.g. SSM SOURCES).
Expand All @@ -176,9 +166,9 @@ def _unstructure_codegen_v2(value: Any) -> dict[str, Any]:
except ImportError:
_DaskArray = type(None) # type: ignore[misc,assignment]

for f in attrs.fields(cls):
for f in attrs.fields(cls): # type: ignore[arg-type]
meta = f.metadata
block_name = meta.get("dfn_block")
block_name = meta.get("block")
if not block_name:
continue

Expand All @@ -192,7 +182,7 @@ def _unstructure_codegen_v2(value: Any) -> dict[str, Any]:
if field_value is None:
continue

dfn_type = meta.get("dfn_type", "")
dfn_type = to_field_type(f.type)

# ── PERIOD block ────────────────────────────────────────────────────────
if block_name == "period":
Expand Down Expand Up @@ -264,7 +254,7 @@ def _unstructure_codegen_v2(value: Any) -> dict[str, Any]:
if field_value:
blocks[block_name][f.name] = field_value

elif dfn_type == "record" and isinstance(field_value, Path):
elif meta.get("inout") and isinstance(field_value, Path):
t = _path_to_tuple(f.name, field_value, meta.get("inout", "fileout"))
blocks[block_name][t[0].lower()] = t

Expand Down Expand Up @@ -388,17 +378,14 @@ def _unstructure_codegen_v2(value: Any) -> dict[str, Any]:


def unstructure_component(value: Component) -> dict[str, Any]:
if has_dfn_metadata(type(value)):
return _unstructure_codegen_v2(value)
# temporary; TODO unify once xattree is fully gone
if isinstance(value, Package):
return _unstructure_package(value)
return _unstructure_component(value)


def _unstructure_component(value: Component) -> dict[str, Any]:
"""Unstructure a model-level xattree component (Gwf, Simulation, etc.).

Model-level classes only have options blocks (bools, paths, inner records,
strings) and child bindings. They have no griddata, period, or list blocks.
"""
"""Unstructure a xattree component (Gwf, Simulation, etc.)."""
blockspec = blocks_dict(type(value))
blocks: dict[str, dict[str, Any]] = {}
xatspec = xattree.get_xatspec(type(value))
Expand Down
Loading
Loading