Skip to content

Commit 7bd0f2b

Browse files
committed
feat: add DataFrame support for period data API (#201)
- Added to_dataframe() method to Package class for exporting period data - Added from_dataframe() classmethod to create packages from DataFrames - Supports automatic field detection for to_dataframe() - Works with structured (layer/row/col) and unstructured (node) grids - Handles multiple stress periods and multiple cells per period - Comprehensive tests for CHD, WEL, DRN packages - Round-trip conversion tested (dict -> DataFrame -> dict)
1 parent fe39f4b commit 7bd0f2b

File tree

2 files changed

+379
-0
lines changed

2 files changed

+379
-0
lines changed

flopy4/mf6/package.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
from abc import ABC
2+
from typing import Optional
23

4+
import numpy as np
5+
import pandas as pd
36
from xattree import xattree
47

58
from flopy4.mf6.component import Component
9+
from flopy4.mf6.constants import FILL_DNODATA
610

711

812
@xattree
@@ -11,3 +15,138 @@ def default_filename(self) -> str:
1115
name = self.parent.name if self.parent else self.name # type: ignore
1216
cls_name = self.__class__.__name__.lower()
1317
return f"{name}.{cls_name}"
18+
19+
def to_dataframe(self, field_name: Optional[str] = None) -> pd.DataFrame:
20+
"""
21+
Convert period data to pandas DataFrame.
22+
23+
Parameters
24+
----------
25+
field_name : str, optional
26+
Name of the period field to convert. If None, attempts
27+
to find the first period block field automatically.
28+
29+
Returns
30+
-------
31+
pd.DataFrame
32+
DataFrame with columns: 'per', 'layer', 'row', 'col',
33+
and field value column(s).
34+
35+
Examples
36+
--------
37+
>>> chd = Chd(parent=gwf, head={0: {(0, 0, 0): 1.0}})
38+
>>> df = chd.to_dataframe('head')
39+
>>> print(df)
40+
per layer row col head
41+
0 0 0 0 0 1.0
42+
"""
43+
from attrs import fields
44+
45+
# If no field name provided, find first period block field
46+
if field_name is None:
47+
for f in fields(self.__class__):
48+
if f.metadata and f.metadata.get("block") == "period":
49+
if f.metadata.get("xattree", {}).get("dims"):
50+
field_name = f.name
51+
break
52+
53+
if field_name is None:
54+
raise ValueError("No period block field found in package")
55+
56+
# Get the field data
57+
data = getattr(self, field_name)
58+
if data is None:
59+
return pd.DataFrame()
60+
61+
# Convert xarray to DataFrame
62+
records = []
63+
for per in range(data.shape[0]):
64+
per_data = data[per]
65+
# Find non-empty cells
66+
mask = per_data != FILL_DNODATA
67+
if isinstance(mask, np.ndarray):
68+
indices = np.where(mask)
69+
values = per_data[mask]
70+
71+
for i in range(len(values)):
72+
if len(indices) == 1: # 1D array (nodes)
73+
node = indices[0][i]
74+
record = {"per": per, "node": node, field_name: values[i]}
75+
elif len(indices) == 3: # 3D array (layer, row, col)
76+
layer, row, col = indices[0][i], indices[1][i], indices[2][i]
77+
record = {
78+
"per": per,
79+
"layer": layer,
80+
"row": row,
81+
"col": col,
82+
field_name: values[i],
83+
}
84+
else:
85+
continue
86+
records.append(record)
87+
88+
return pd.DataFrame(records)
89+
90+
@classmethod
91+
def from_dataframe(
92+
cls, df: pd.DataFrame, field_name: str, dims: dict, **kwargs
93+
) -> "Package":
94+
"""
95+
Create package from pandas DataFrame.
96+
97+
Parameters
98+
----------
99+
df : pd.DataFrame
100+
DataFrame with period data. Must contain 'per' column
101+
and spatial index columns ('layer', 'row', 'col' or 'node').
102+
field_name : str
103+
Name of the field column in the DataFrame.
104+
dims : dict
105+
Dictionary of dimension sizes (nper, nlay, nrow, ncol, nodes).
106+
**kwargs
107+
Additional package parameters.
108+
109+
Returns
110+
-------
111+
Package
112+
Instantiated package.
113+
114+
Examples
115+
--------
116+
>>> df = pd.DataFrame({
117+
... 'per': [0, 0],
118+
... 'layer': [0, 0],
119+
... 'row': [0, 9],
120+
... 'col': [0, 9],
121+
... 'head': [1.0, 0.0]
122+
... })
123+
>>> chd = Chd.from_dataframe(df, 'head', dims={'nper': 1, 'nodes': 100})
124+
"""
125+
# Determine if structured or unstructured
126+
has_structured_coords = all(c in df.columns for c in ["layer", "row", "col"])
127+
has_node_coord = "node" in df.columns
128+
129+
if not (has_structured_coords or has_node_coord):
130+
raise ValueError(
131+
"DataFrame must contain either (layer, row, col) or (node) columns"
132+
)
133+
134+
# Create period data dict
135+
period_data = {}
136+
for per in df["per"].unique():
137+
per_df = df[df["per"] == per]
138+
period_data[int(per)] = {}
139+
140+
for _, row in per_df.iterrows():
141+
if has_structured_coords:
142+
cellid = (int(row["layer"]), int(row["row"]), int(row["col"]))
143+
else:
144+
cellid = (int(row["node"]),)
145+
146+
period_data[int(per)][cellid] = row[field_name]
147+
148+
# Create kwargs with the period data
149+
package_kwargs = {field_name: period_data, "dims": dims}
150+
package_kwargs.update(kwargs)
151+
152+
return cls(**package_kwargs)

test/test_dataframe_api.py

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
"""Tests for DataFrame API for period data."""
2+
3+
import numpy as np
4+
import pandas as pd
5+
import pytest
6+
7+
from flopy4.mf6.constants import FILL_DNODATA
8+
from flopy4.mf6.gwf import Chd, Drn, Gwf, Wel
9+
from flopy4.mf6.utils.grid import StructuredGrid
10+
from flopy4.mf6.utils.time import Time
11+
12+
13+
def test_chd_to_dataframe():
14+
"""Test converting CHD package to DataFrame."""
15+
time = Time(perlen=[1.0], nstp=[1])
16+
grid = StructuredGrid(nlay=1, nrow=10, ncol=10)
17+
dims = {
18+
"nlay": grid.nlay,
19+
"nrow": grid.nrow,
20+
"ncol": grid.ncol,
21+
"nper": time.nper,
22+
"nodes": grid.nnodes,
23+
}
24+
25+
chd = Chd(dims=dims, head={0: {(0, 0, 0): 1.0, (0, 9, 9): 0.0}})
26+
df = chd.to_dataframe("head")
27+
28+
assert isinstance(df, pd.DataFrame)
29+
assert len(df) == 2
30+
assert list(df.columns) == ["per", "layer", "row", "col", "head"]
31+
32+
# Check first record
33+
assert df.iloc[0]["per"] == 0
34+
assert df.iloc[0]["layer"] == 0
35+
assert df.iloc[0]["row"] == 0
36+
assert df.iloc[0]["col"] == 0
37+
assert df.iloc[0]["head"] == 1.0
38+
39+
# Check second record
40+
assert df.iloc[1]["per"] == 0
41+
assert df.iloc[1]["layer"] == 0
42+
assert df.iloc[1]["row"] == 9
43+
assert df.iloc[1]["col"] == 9
44+
assert df.iloc[1]["head"] == 0.0
45+
46+
47+
def test_chd_from_dataframe():
48+
"""Test creating CHD package from DataFrame."""
49+
df = pd.DataFrame(
50+
{
51+
"per": [0, 0],
52+
"layer": [0, 0],
53+
"row": [0, 9],
54+
"col": [0, 9],
55+
"head": [1.0, 0.0],
56+
}
57+
)
58+
59+
dims = {"nper": 1, "nlay": 1, "nrow": 10, "ncol": 10, "nodes": 100}
60+
chd = Chd.from_dataframe(df, "head", dims=dims)
61+
62+
assert chd.head is not None
63+
assert chd.head.shape == (1, 100)
64+
65+
# Check that the correct cells have values
66+
assert chd.head[0, 0] == 1.0 # (0, 0, 0) -> node 0
67+
assert chd.head[0, 99] == 0.0 # (0, 9, 9) -> node 99
68+
69+
70+
def test_chd_roundtrip():
71+
"""Test round-trip conversion: dict -> to_dataframe -> from_dataframe."""
72+
dims = {"nper": 1, "nlay": 1, "nrow": 10, "ncol": 10, "nodes": 100}
73+
74+
# Create original package
75+
chd1 = Chd(dims=dims, head={0: {(0, 0, 0): 1.0, (0, 9, 9): 0.0}})
76+
77+
# Convert to DataFrame
78+
df = chd1.to_dataframe("head")
79+
80+
# Create new package from DataFrame
81+
chd2 = Chd.from_dataframe(df, "head", dims=dims)
82+
83+
# Compare
84+
assert np.allclose(chd1.head, chd2.head, equal_nan=True)
85+
86+
87+
def test_wel_to_dataframe():
88+
"""Test converting WEL package to DataFrame."""
89+
dims = {"nper": 1, "nlay": 1, "nrow": 10, "ncol": 10, "nodes": 100}
90+
91+
wel = Wel(
92+
dims=dims,
93+
q={0: {(0, 5, 5): -100.0, (0, 8, 8): 50.0}},
94+
)
95+
df = wel.to_dataframe("q")
96+
97+
assert isinstance(df, pd.DataFrame)
98+
assert len(df) == 2
99+
assert list(df.columns) == ["per", "layer", "row", "col", "q"]
100+
101+
# Check records
102+
assert df.iloc[0]["q"] == -100.0
103+
assert df.iloc[1]["q"] == 50.0
104+
105+
106+
def test_wel_from_dataframe():
107+
"""Test creating WEL package from DataFrame."""
108+
df = pd.DataFrame(
109+
{
110+
"per": [0, 0],
111+
"layer": [0, 0],
112+
"row": [5, 8],
113+
"col": [5, 8],
114+
"q": [-100.0, 50.0],
115+
}
116+
)
117+
118+
dims = {"nper": 1, "nlay": 1, "nrow": 10, "ncol": 10, "nodes": 100}
119+
wel = Wel.from_dataframe(df, "q", dims=dims)
120+
121+
assert wel.q is not None
122+
assert wel.q.shape == (1, 100)
123+
124+
# Node for (0, 5, 5) = 5*10 + 5 = 55
125+
assert wel.q[0, 55] == -100.0
126+
# Node for (0, 8, 8) = 8*10 + 8 = 88
127+
assert wel.q[0, 88] == 50.0
128+
129+
130+
def test_drn_to_dataframe():
131+
"""Test converting DRN package to DataFrame (multi-field)."""
132+
dims = {"nper": 1, "nlay": 1, "nrow": 10, "ncol": 10, "nodes": 100}
133+
134+
drn = Drn(
135+
dims=dims,
136+
elev={0: {(0, 7, 5): 10.0}},
137+
cond={0: {(0, 7, 5): 1.0}},
138+
)
139+
140+
# Test elevation field
141+
df_elev = drn.to_dataframe("elev")
142+
assert len(df_elev) == 1
143+
assert df_elev.iloc[0]["elev"] == 10.0
144+
145+
# Test conductance field
146+
df_cond = drn.to_dataframe("cond")
147+
assert len(df_cond) == 1
148+
assert df_cond.iloc[0]["cond"] == 1.0
149+
150+
151+
def test_multi_period_dataframe():
152+
"""Test DataFrame conversion with multiple stress periods."""
153+
dims = {"nper": 3, "nlay": 1, "nrow": 10, "ncol": 10, "nodes": 100}
154+
155+
chd = Chd(
156+
dims=dims,
157+
head={
158+
0: {(0, 0, 0): 1.0},
159+
1: {(0, 0, 0): 0.9},
160+
2: {(0, 0, 0): 0.8},
161+
},
162+
)
163+
164+
df = chd.to_dataframe("head")
165+
166+
assert len(df) == 3
167+
assert df[df["per"] == 0].iloc[0]["head"] == 1.0
168+
assert df[df["per"] == 1].iloc[0]["head"] == 0.9
169+
assert df[df["per"] == 2].iloc[0]["head"] == 0.8
170+
171+
172+
def test_dataframe_with_multiple_cells():
173+
"""Test DataFrame conversion with multiple cells per period."""
174+
df = pd.DataFrame(
175+
{
176+
"per": [0, 0, 0, 1, 1, 1],
177+
"layer": [0, 0, 0, 0, 0, 0],
178+
"row": [0, 5, 9, 0, 5, 9],
179+
"col": [0, 5, 9, 0, 5, 9],
180+
"head": [1.0, 0.5, 0.0, 0.9, 0.45, 0.0],
181+
}
182+
)
183+
184+
dims = {"nper": 2, "nlay": 1, "nrow": 10, "ncol": 10, "nodes": 100}
185+
chd = Chd.from_dataframe(df, "head", dims=dims)
186+
187+
# Verify period 0
188+
assert chd.head[0, 0] == 1.0
189+
assert chd.head[0, 55] == 0.5
190+
assert chd.head[0, 99] == 0.0
191+
192+
# Verify period 1
193+
assert chd.head[1, 0] == 0.9
194+
assert chd.head[1, 55] == 0.45
195+
assert chd.head[1, 99] == 0.0
196+
197+
198+
def test_to_dataframe_auto_detect_field():
199+
"""Test automatic field detection in to_dataframe."""
200+
dims = {"nper": 1, "nlay": 1, "nrow": 10, "ncol": 10, "nodes": 100}
201+
202+
chd = Chd(dims=dims, head={0: {(0, 0, 0): 1.0}})
203+
204+
# Should auto-detect 'head' field
205+
df = chd.to_dataframe()
206+
assert "head" in df.columns
207+
assert len(df) == 1
208+
209+
210+
def test_empty_dataframe():
211+
"""Test converting empty package to DataFrame."""
212+
dims = {"nper": 1, "nlay": 1, "nrow": 10, "ncol": 10, "nodes": 100}
213+
214+
chd = Chd(dims=dims) # No head data
215+
df = chd.to_dataframe("head")
216+
217+
assert isinstance(df, pd.DataFrame)
218+
assert len(df) == 0
219+
220+
221+
def test_from_dataframe_with_kwargs():
222+
"""Test from_dataframe with additional package parameters."""
223+
df = pd.DataFrame(
224+
{
225+
"per": [0, 0],
226+
"layer": [0, 0],
227+
"row": [0, 9],
228+
"col": [0, 9],
229+
"head": [1.0, 0.0],
230+
}
231+
)
232+
233+
dims = {"nper": 1, "nlay": 1, "nrow": 10, "ncol": 10, "nodes": 100}
234+
chd = Chd.from_dataframe(
235+
df, "head", dims=dims, print_input=True, print_flows=True
236+
)
237+
238+
assert chd.print_input is True
239+
assert chd.print_flows is True
240+
assert chd.head is not None

0 commit comments

Comments
 (0)