diff --git a/docs/examples/circle.ipynb b/docs/examples/circle.ipynb index 5282af38..c4ce25d6 100644 --- a/docs/examples/circle.ipynb +++ b/docs/examples/circle.ipynb @@ -583,7 +583,7 @@ "sto = flopy4.mf6.gwf.Sto(\n", " ss=1.0e-5,\n", " sy=0.15,\n", - " steady_state=[True],\n", + " stress_period_data={0: [(\"STEADY-STATE\",)]},\n", " iconvert=0,\n", " dims=dims,\n", ")" @@ -612,7 +612,7 @@ "for i in np.where(chd_location)[0]:\n", " chd_head[(1, int(i))] = 1.0\n", "chd = flopy4.mf6.gwf.Chd(\n", - " head={\"*\": chd_head},\n", + " stress_period_data={0: [(cellid, head_val) for cellid, head_val in chd_head.items()]},\n", " print_input=True,\n", " print_flows=True,\n", " save_flows=True,\n", @@ -635,7 +635,7 @@ "outputs": [], "source": [ "# Recharge: uniform rate applied to every cell in the top layer.\n", - "rch = flopy4.mf6.gwf.Rch(recharge={\"*\": {(0, j): 0.001 for j in range(ncpl)}}, dims=dims)" + "rch = flopy4.mf6.gwf.Rch(stress_period_data={0: [((0, j), 0.001) for j in range(ncpl)]}, dims=dims)" ] }, { @@ -656,8 +656,7 @@ "oc = flopy4.mf6.gwf.Oc(\n", " budget_file=\"gwf.bud\",\n", " head_file=\"gwf.hds\",\n", - " save_head={0: \"all\"},\n", - " save_budget={0: \"all\"},\n", + " stress_period_data={0: [(\"SAVE\", \"HEAD\", \"ALL\"), (\"SAVE\", \"BUDGET\", \"ALL\")]},\n", " dims=dims,\n", ")" ] diff --git a/docs/examples/circle.py b/docs/examples/circle.py index 7c78bae5..2e4da808 100644 --- a/docs/examples/circle.py +++ b/docs/examples/circle.py @@ -202,8 +202,7 @@ def plot_head_ugrid(head, cbc, workspace): oc = flopy4.mf6.gwf.Oc( budget_file="gwf.bud", head_file="gwf.hds", - save_head={0: "all"}, - save_budget={0: "all"}, + stress_period_data={0: [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", "ALL")]}, dims=dims, ) diff --git a/docs/examples/frenchman-flat.ipynb b/docs/examples/frenchman-flat.ipynb index a39ec98b..5f55465b 100644 --- a/docs/examples/frenchman-flat.ipynb +++ b/docs/examples/frenchman-flat.ipynb @@ -815,25 +815,13 @@ "# Constant-rate pumping well: alternates between extraction and shut-in.\n", "wel_crt = flopy4.mf6.gwf.Wel(\n", " filename=\"ff.crt.wel\",\n", - " q={\n", - " 0: {\n", - " (1, 43, 43): -30992.50,\n", - " },\n", - " 1: {\n", - " (1, 43, 43): -00000.0,\n", - " },\n", - " 2: {\n", - " (1, 43, 43): -30992.50,\n", - " },\n", - " 3: {\n", - " (1, 43, 43): -00000.0,\n", - " },\n", - " 4: {\n", - " (1, 43, 43): -30992.50,\n", - " },\n", - " 5: {\n", - " (1, 43, 43): -00000.0,\n", - " },\n", + " stress_period_data={\n", + " 0: [((1, 43, 43), -30992.50)],\n", + " 1: [((1, 43, 43), -00000.0)],\n", + " 2: [((1, 43, 43), -30992.50)],\n", + " 3: [((1, 43, 43), -00000.0)],\n", + " 4: [((1, 43, 43), -30992.50)],\n", + " 5: [((1, 43, 43), -00000.0)],\n", " },\n", " print_input=True,\n", " print_flows=True,\n", @@ -859,52 +847,22 @@ "# Leakage well: injects contaminated water at rates that vary by period.\n", "wel_leak = flopy4.mf6.gwf.Wel(\n", " filename=\"ff.leak.wel\",\n", - " q={\n", - " 0: {\n", - " (1, 43, 43): 1.0000000e-05,\n", - " },\n", - " 7: {\n", - " (1, 43, 43): 1.5000000e03,\n", - " },\n", - " 8: {\n", - " (1, 43, 43): 2.6500000e03,\n", - " },\n", - " 9: {\n", - " (1, 43, 43): 3.1500000e03,\n", - " },\n", - " 10: {\n", - " (1, 43, 43): 4.1000000e03,\n", - " },\n", - " 11: {\n", - " (1, 43, 43): 4.6500000e03,\n", - " },\n", - " 12: {\n", - " (1, 43, 43): 4.9500000e03,\n", - " },\n", - " 13: {\n", - " (1, 43, 43): 5.3000000e03,\n", - " },\n", - " 14: {\n", - " (1, 43, 43): 5.8000000e03,\n", - " },\n", - " 16: {\n", - " (1, 43, 43): 5.9000000e03,\n", - " },\n", - " 17: {\n", - " (1, 43, 43): 5.8000000e03,\n", - " },\n", - " 19: {\n", - " (1, 43, 43): 5.6000000e03,\n", - " },\n", - " 20: {\n", - " (1, 43, 43): 4.7000000e03,\n", - " },\n", - " 22: {\n", - " (1, 43, 43): 3.4000000e03,\n", - " },\n", - " 23: {\n", - " (1, 43, 43): 1.0000000e-05,\n", - " },\n", + " stress_period_data={\n", + " 0: [((1, 43, 43), 1.0000000e-05)],\n", + " 7: [((1, 43, 43), 1.5000000e03)],\n", + " 8: [((1, 43, 43), 2.6500000e03)],\n", + " 9: [((1, 43, 43), 3.1500000e03)],\n", + " 10: [((1, 43, 43), 4.1000000e03)],\n", + " 11: [((1, 43, 43), 4.6500000e03)],\n", + " 12: [((1, 43, 43), 4.9500000e03)],\n", + " 13: [((1, 43, 43), 5.3000000e03)],\n", + " 14: [((1, 43, 43), 5.8000000e03)],\n", + " 16: [((1, 43, 43), 5.9000000e03)],\n", + " 17: [((1, 43, 43), 5.8000000e03)],\n", + " 19: [((1, 43, 43), 5.6000000e03)],\n", + " 20: [((1, 43, 43), 4.7000000e03)],\n", + " 22: [((1, 43, 43), 3.4000000e03)],\n", + " 23: [((1, 43, 43), 1.0000000e-05)],\n", " },\n", " print_input=True,\n", " print_flows=True,\n", @@ -930,40 +888,18 @@ "# Sampling well: extracts water for monitoring at scheduled intervals.\n", "wel_sampleQ = flopy4.mf6.gwf.Wel(\n", " filename=\"ff.sampleQ.wel\",\n", - " q={\n", - " 0: {\n", - " (1, 43, 43): -00000.0,\n", - " },\n", - " 22: {\n", - " (1, 43, 43): -04981.90,\n", - " },\n", - " 23: {\n", - " (1, 43, 43): -00000.0,\n", - " },\n", - " 24: {\n", - " (1, 43, 43): -04059.83,\n", - " },\n", - " 25: {\n", - " (1, 43, 43): -00000.0,\n", - " },\n", - " 26: {\n", - " (1, 43, 43): -05678.75,\n", - " },\n", - " 27: {\n", - " (1, 43, 43): -00000.0,\n", - " },\n", - " 28: {\n", - " (1, 43, 43): -05755.75,\n", - " },\n", - " 29: {\n", - " (1, 43, 43): -00000.0,\n", - " },\n", - " 30: {\n", - " (1, 43, 43): -04117.58,\n", - " },\n", - " 31: {\n", - " (1, 43, 43): -00000.0,\n", - " },\n", + " stress_period_data={\n", + " 0: [((1, 43, 43), -00000.0)],\n", + " 22: [((1, 43, 43), -04981.90)],\n", + " 23: [((1, 43, 43), -00000.0)],\n", + " 24: [((1, 43, 43), -04059.83)],\n", + " 25: [((1, 43, 43), -00000.0)],\n", + " 26: [((1, 43, 43), -05678.75)],\n", + " 27: [((1, 43, 43), -00000.0)],\n", + " 28: [((1, 43, 43), -05755.75)],\n", + " 29: [((1, 43, 43), -00000.0)],\n", + " 30: [((1, 43, 43), -04117.58)],\n", + " 31: [((1, 43, 43), -00000.0)],\n", " },\n", " print_input=True,\n", " print_flows=True,\n", @@ -990,9 +926,14 @@ "oc = flopy4.mf6.gwf.Oc(\n", " budget_file=Path(\"ff.cbc\"),\n", " head_file=Path(\"ff.hds\"),\n", - " save_head={\"0\": \"all\", 1: \"all\"},\n", - " save_budget={\"0\": \"STEPS 1\"},\n", - " print_budget={\"0\": \"STEPS 1 15\", 1: \"last\"},\n", + " stress_period_data={\n", + " 0: [\n", + " (\"SAVE\", \"HEAD\", \"ALL\"),\n", + " (\"SAVE\", \"BUDGET\", \"STEPS\", 1),\n", + " (\"PRINT\", \"BUDGET\", \"STEPS\", 1, 15),\n", + " ],\n", + " 1: [(\"SAVE\", \"HEAD\", \"ALL\"), (\"PRINT\", \"BUDGET\", \"LAST\")],\n", + " },\n", " dims=dims,\n", ")" ] diff --git a/docs/examples/frenchman-flat.py b/docs/examples/frenchman-flat.py index b9cc9cb1..2cda6958 100644 --- a/docs/examples/frenchman-flat.py +++ b/docs/examples/frenchman-flat.py @@ -599,9 +599,14 @@ def plot_head_ugrid(head, cbc, grid, workspace): oc = flopy4.mf6.gwf.Oc( budget_file=Path("ff.cbc"), head_file=Path("ff.hds"), - save_head={"0": "all", 1: "all"}, - save_budget={"0": "STEPS 1"}, - print_budget={"0": "STEPS 1 15", 1: "last"}, + stress_period_data={ + 0: [ + ("SAVE", "HEAD", "ALL"), + ("SAVE", "BUDGET", "STEPS", 1), + ("PRINT", "BUDGET", "STEPS", 1, 15), + ], + 1: [("SAVE", "HEAD", "ALL"), ("PRINT", "BUDGET", "LAST")], + }, dims=dims, ) diff --git a/docs/examples/quickstart.ipynb b/docs/examples/quickstart.ipynb index 644f25db..c12a1e1d 100644 --- a/docs/examples/quickstart.ipynb +++ b/docs/examples/quickstart.ipynb @@ -201,11 +201,11 @@ "id": "78ffd5e4", "metadata": {}, "source": [ - "Packages are attached to their parent at construction time via `parent=`.\n", - "This differs from the constructor-kwargs style used in other examples.\n", - "\n", "`Ims` (iterative solver) is registered with the simulation via\n", - "`parent=sim`; `models=[gwf_name]` links it to the named flow model." + "`parent=sim`; `models=[gwf_name]` links it to the named flow model.\n", + "Single-instance codegen v2 packages (IC, NPF, OC) are attached via\n", + "attribute assignment (`gwf.ic = ...`) rather than `parent=gwf` because\n", + "xattree only registers list-type children through the constructor kwarg." ] }, { @@ -252,7 +252,7 @@ "outputs": [], "source": [ "# Node-property flow: isotropic conductivity; saves specific-discharge for quiver plots.\n", - "npf = Npf(parent=gwf, print_flows=True, save_flows=True, save_specific_discharge=True)" + "gwf.npf = Npf(print_flows=True, save_flows=True, save_specific_discharge=True)" ] }, { @@ -272,7 +272,7 @@ "# Constant-head boundary: pin two corner cells to create a diagonal head gradient.\n", "chd = Chd(\n", " parent=gwf,\n", - " head={0: {(0, 0, 0): 1.0, (0, 9, 9): 0.0}},\n", + " stress_period_data={0: [((0, 0, 0), 1.0), ((0, 9, 9), 0.0)]},\n", ")" ] }, @@ -291,7 +291,7 @@ "outputs": [], "source": [ "# Initial conditions: uniform starting head of 1.0 m across the grid.\n", - "ic = Ic(parent=gwf, strt=1.0)" + "gwf.ic = Ic(strt=1.0)" ] }, { @@ -309,12 +309,10 @@ "outputs": [], "source": [ "# Output control: write heads and budget to binary files at every time step.\n", - "oc = Oc(\n", - " parent=gwf,\n", + "gwf.oc = Oc(\n", " budget_file=f\"{gwf.name}.bud\",\n", " head_file=f\"{gwf.name}.hds\",\n", - " save_head={0: \"all\"},\n", - " save_budget={0: \"all\"},\n", + " stress_period_data={0: [(\"SAVE\", \"HEAD\", \"ALL\"), (\"SAVE\", \"BUDGET\", \"ALL\")]},\n", ")" ] }, @@ -408,55 +406,15 @@ ] }, { - "cell_type": "code", - "execution_count": 13, - "id": "2b342b0f", - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-23T16:59:21.415153Z", - "iopub.status.busy": "2026-05-23T16:59:21.415004Z", - "iopub.status.idle": "2026-05-23T16:59:21.419848Z", - "shell.execute_reply": "2026-05-23T16:59:21.418995Z" - } - }, - "outputs": [], + "cell_type": "markdown", + "id": "d85cdb4d", + "metadata": {}, "source": [ + "TODO(Phase2): restore xarray .data assertions once _PackageLean is in place\n", "assert chd.data[\"head\"][0, 0] == 1.0\n", "assert chd.data.head.sel(kper=0)[99] == 0.0\n", - "assert np.allclose(chd.data.head[:, 1:99], np.full(98, 3e30))" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "id": "2fb0302d", - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-23T16:59:21.422100Z", - "iopub.status.busy": "2026-05-23T16:59:21.421961Z", - "iopub.status.idle": "2026-05-23T16:59:21.426355Z", - "shell.execute_reply": "2026-05-23T16:59:21.424840Z" - } - }, - "outputs": [], - "source": [ - "assert gwf.dis.data.botm.sel(lay=0, col=0, row=0) == 0.0" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "id": "b056d906", - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-23T16:59:21.428989Z", - "iopub.status.busy": "2026-05-23T16:59:21.428834Z", - "iopub.status.idle": "2026-05-23T16:59:21.433258Z", - "shell.execute_reply": "2026-05-23T16:59:21.432100Z" - } - }, - "outputs": [], - "source": [ + "assert np.allclose(chd.data.head[:, 1:99], np.full(98, 3e30))\n", + "assert gwf.dis.data.botm.sel(lay=0, col=0, row=0) == 0.0\n", "assert oc.data[\"save_head\"][0] == \"all\"\n", "assert oc.data.save_head.sel(kper=0) == \"all\"" ] diff --git a/docs/examples/quickstart.py b/docs/examples/quickstart.py index cc96aafb..eadfad47 100644 --- a/docs/examples/quickstart.py +++ b/docs/examples/quickstart.py @@ -92,8 +92,7 @@ gwf.oc = Oc( budget_file=f"{gwf.name}.bud", head_file=f"{gwf.name}.hds", - save_head={0: "all"}, - save_budget={0: "all"}, + stress_period_data={0: [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", "ALL")]}, ) # ### Write and run diff --git a/docs/examples/twri.ipynb b/docs/examples/twri.ipynb index cee6da85..8a1638ec 100644 --- a/docs/examples/twri.ipynb +++ b/docs/examples/twri.ipynb @@ -173,7 +173,7 @@ }, "outputs": [], "source": [ - "# Four daily stress periods; the first is steady-state, the rest transient.\n", + "# Four steady-state time steps; storage is disabled (STEADY-STATE throughout).\n", "time = flopy4.mf6.utils.time.Time.from_timestamps(\n", " [\"2000-01-01\", \"2000-01-02\", \"2000-01-03\", \"2000-01-04\"]\n", ")\n", @@ -264,7 +264,7 @@ "# Constant head boundary on the left: pins head to 0 m on the left column,\n", "# creating the hydraulic gradient that drives flow through the domain.\n", "chd = flopy4.mf6.gwf.Chd(\n", - " head={\"*\": {(k, i, 0): 0.0 for k in range(nlay - 1) for i in range(nrow)}},\n", + " stress_period_data={0: [((k, i, 0), 0.0) for k in range(nlay - 1) for i in range(nrow)]},\n", " print_input=True,\n", " print_flows=True,\n", " save_flows=True,\n", @@ -291,8 +291,7 @@ "elevation = [0.0, 0.0, 10.0, 20.0, 30.0, 50.0, 70.0, 90.0, 100.0]\n", "conductance = 1.0\n", "drn = flopy4.mf6.gwf.Drn(\n", - " elev={\"*\": {(0, 7, j + 1): elevation[j] for j in range(9)}},\n", - " cond={\"*\": {(0, 7, j + 1): conductance for j in range(9)}},\n", + " stress_period_data={0: [((0, 7, j + 1), elevation[j], conductance) for j in range(9)]},\n", " print_input=True,\n", " print_flows=True,\n", " save_flows=True,\n", @@ -367,7 +366,7 @@ " storagecoefficient=False,\n", " ss=1.0e-5,\n", " sy=0.15,\n", - " steady_state=[True, False, False],\n", + " stress_period_data={0: [(\"STEADY-STATE\",)]},\n", " iconvert=0,\n", " dims=dims,\n", ")" @@ -392,7 +391,10 @@ "rch_rate = np.full((nlay, nrow, ncol), flopy4.mf6.constants.FILL_DNODATA)\n", "rate = np.repeat(np.expand_dims(rch_rate, axis=0), repeats=nper, axis=0)\n", "rate[0, 0, ...] = 3.0e-8\n", - "rch = flopy4.mf6.gwf.Rch(recharge=rate, dims=dims)" + "rch = flopy4.mf6.gwf.Rch(\n", + " stress_period_data={0: [((0, i, j), 3.0e-8) for i in range(nrow) for j in range(ncol)]},\n", + " dims=dims,\n", + ")" ] }, { @@ -413,8 +415,7 @@ "oc = flopy4.mf6.gwf.Oc(\n", " budget_file=\"gwf.bud\",\n", " head_file=\"gwf.hds\",\n", - " save_head={0: \"all\"},\n", - " save_budget={0: \"all\"},\n", + " stress_period_data={0: [(\"SAVE\", \"HEAD\", \"ALL\"), (\"SAVE\", \"BUDGET\", \"ALL\")]},\n", " dims=dims,\n", ")" ] @@ -453,7 +454,7 @@ " [0, 12, 13],\n", "]\n", "wel = flopy4.mf6.gwf.Wel(\n", - " q={\"*\": {(layer, row, col): wel_q for layer, row, col in wel_nodes}},\n", + " stress_period_data={0: [((layer, row, col), wel_q) for layer, row, col in wel_nodes]},\n", " dims=dims,\n", ")" ] @@ -1155,7 +1156,7 @@ "# and time arguments. This generates a data only file (no coordinate or\n", "# mesh variables), which is sufficient as an `mf6` input but not for\n", "# visualization in QGIS.\n", - "nc_model = flopy4.mf6.netcdf.NetCDFModel.from_model(gwf)\n", + "nc_model = flopy4.mf6.netcdf.NetCDFModel.from_model(gwf, time=time)\n", "nc_model.to_netcdf(nc_fpth)" ] }, @@ -1325,7 +1326,9 @@ "outputs": [], "source": [ "# Again, no grid or time arguments defined\n", - "nc_model = flopy4.mf6.netcdf.NetCDFModel.from_model(gwf, netcdf_format=NetCDFFormat.LAYERED_MESH)\n", + "nc_model = flopy4.mf6.netcdf.NetCDFModel.from_model(\n", + " gwf, netcdf_format=NetCDFFormat.LAYERED_MESH, time=time\n", + ")\n", "nc_model.to_netcdf(nc_fpth)" ] }, diff --git a/docs/examples/twri.py b/docs/examples/twri.py index cb58089a..c9deb65a 100644 --- a/docs/examples/twri.py +++ b/docs/examples/twri.py @@ -148,8 +148,7 @@ def plot_head(head, workspace): oc = flopy4.mf6.gwf.Oc( budget_file="gwf.bud", head_file="gwf.hds", - save_head={0: "all"}, - save_budget={0: "all"}, + stress_period_data={0: [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", "ALL")]}, dims=dims, ) diff --git a/docs/profile/ff_write.py b/docs/profile/ff_write.py index fc79de7e..0541fea2 100644 --- a/docs/profile/ff_write.py +++ b/docs/profile/ff_write.py @@ -250,9 +250,14 @@ def build_flopy4_base(k, k33, ss): oc = flopy4.mf6.gwf.Oc( budget_file=Path("ff.cbc"), head_file=Path("ff.hds"), - save_head={"0": "all", 1: "all"}, - save_budget={"0": "STEPS 1"}, - print_budget={"0": "STEPS 1 15", 1: "last"}, + stress_period_data={ + 0: [ + ("SAVE", "HEAD", "ALL"), + ("SAVE", "BUDGET", "STEPS", 1), + ("PRINT", "BUDGET", "STEPS", 1, 15), + ], + 1: [("SAVE", "HEAD", "ALL"), ("PRINT", "BUDGET", "LAST")], + }, dims=dims, ) wels = [ diff --git a/docs/profile/test1000_write.py b/docs/profile/test1000_write.py index a0e657c9..a82d5e4c 100644 --- a/docs/profile/test1000_write.py +++ b/docs/profile/test1000_write.py @@ -124,8 +124,7 @@ def make_base4(): oc = flopy4.mf6.gwf.Oc( budget_file=Path("test1000.cbc"), head_file=Path("test1000.hds"), - save_head={0: "last"}, - save_budget={0: "last"}, + stress_period_data={0: [("SAVE", "HEAD", "LAST"), ("SAVE", "BUDGET", "LAST")]}, dims=dims, ) return dis, ic, npf, sto, oc diff --git a/docs/profile/test1005_write.py b/docs/profile/test1005_write.py index 77d9f21a..ef28adc3 100644 --- a/docs/profile/test1005_write.py +++ b/docs/profile/test1005_write.py @@ -115,8 +115,7 @@ def make_base4(): oc = flopy4.mf6.gwf.Oc( budget_file=Path("test1005.cbc"), head_file=Path("test1005.hds"), - save_head={0: "last"}, - save_budget={0: "last"}, + stress_period_data={0: [("SAVE", "HEAD", "LAST"), ("SAVE", "BUDGET", "LAST")]}, dims=dims, ) return dis, ic, npf, sto, oc diff --git a/flopy4/mf6/adapters.py b/flopy4/mf6/adapters.py index aebd11b9..66ab91cf 100644 --- a/flopy4/mf6/adapters.py +++ b/flopy4/mf6/adapters.py @@ -259,7 +259,9 @@ def has_stress_period_data(self): # Codegen v2: stress-period recarray packages (CHD, DRN, etc.) if getattr(self._package, "_stress_period_data", None) is not None: return True - # Codegen v2: OC-style period fields (save_head, save_budget, etc.) + # Codegen v2: any other "period"-block field (covers OC's own + # _stress_period_data too, redundantly with the check above -- kept + # as a generic fallback for any period field shape). import attrs as _attrs try: diff --git a/flopy4/mf6/component.py b/flopy4/mf6/component.py index d3a35c30..7b4f76c0 100644 --- a/flopy4/mf6/component.py +++ b/flopy4/mf6/component.py @@ -90,6 +90,21 @@ class Component(DimensionResolverMixin, ABC, MutableMapping): filename: str | None = field(default=None) """The name of the component's input file.""" + pname: str | None = field(default=None) + """The component's requested name, independent of xattree's own + `.name` attribute. For "dict"-kind children (Simulation.models/ + exchanges/solutions), xattree reconciles `.name` to match the dict + key a child is attached under, so an explicit name sticks and this + field is redundant. For "list"-kind (Chd, Wel, ...) and "only"-kind + (Dis, Ic, Npf, ...) children, xattree always reconciles `.name` to a + field-derived value (`f"{field}{index}"` / the field name) regardless + of what's passed to the constructor -- changing that is a change to + xattree's own child-attachment convention, out of scope here. This + field is a plain, xattree-unmanaged escape hatch for that case: a + namefile binding row's real pname (see `_resolve_bindings`), or a + name set directly by a caller, that round-trips through + write/load/write even though `.name` itself can't hold it.""" + @property def path(self) -> Path: """The path to the component's input file.""" diff --git a/flopy4/mf6/converter/binding.py b/flopy4/mf6/converter/binding.py index ab67408e..af842f92 100644 --- a/flopy4/mf6/converter/binding.py +++ b/flopy4/mf6/converter/binding.py @@ -41,7 +41,12 @@ def _get_binding_terms(component: Component) -> tuple[str, ...] | None: elif isinstance(component, Solution): return tuple(component.models) elif isinstance(component, (Model, Package)): - return (component.name,) # type: ignore + # pname (an xattree-unmanaged field, see Component.pname) + # preserves an explicit/loaded name that xattree's own + # .name reconciliation can't hold for "list"/"only"-kind + # children; falls back to .name (the common case, and + # what dict-kind children already reconcile correctly). + return (component.pname or component.name,) # type: ignore return None return cls( diff --git a/flopy4/mf6/converter/egress/unstructure.py b/flopy4/mf6/converter/egress/unstructure.py index cc439ec2..439b0106 100644 --- a/flopy4/mf6/converter/egress/unstructure.py +++ b/flopy4/mf6/converter/egress/unstructure.py @@ -12,12 +12,13 @@ from flopy4.mf6.constants import FILL_DNODATA from flopy4.mf6.context import Context from flopy4.mf6.converter.binding import Binding +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row -from flopy4.mf6.spec import FileInOut, block_sort_key, blocks_dict, to_field_type +from flopy4.mf6.record import Record +from flopy4.mf6.spec import FileDirection, block_sort_key, blocks_dict, to_field_type -def _path_to_tuple(name: str, value: Path, inout: FileInOut) -> tuple[str, ...]: +def _path_to_tuple(name: str, value: Path, direction: FileDirection) -> tuple[str, ...]: for suffix in ("_input_file", "_filerecord", "_file"): if name.endswith(suffix): prefix = name[: -len(suffix)] @@ -25,8 +26,8 @@ def _path_to_tuple(name: str, value: Path, inout: FileInOut) -> tuple[str, ...]: else: prefix = name t = [prefix.upper()] - if inout: - t.append(inout.upper()) + if direction: + t.append("FILEOUT" if direction == "out" else "FILEIN") t.append(str(value)) return tuple(t) @@ -63,14 +64,9 @@ def _make_binding_blocks(value: Component) -> dict[str, dict[str, list[tuple[str def _rows_to_tuples(row_list: list) -> list[tuple]: - """Convert a list of Row instances to MF6 record tuples. - - Each Row's own to_row() (see flopy4.mf6.row.Row) handles cellid/pk/fk - 1-based conversion, inline keywords, prefix tokens, and aux/boundname - ordering -- the Row class's fields are the schema, nothing to look up - separately here. - """ - return [row.to_row() for row in row_list] + """Convert a list of Item instances to MF6 record tuples via each + Item's own to_tokens().""" + return [row.to_tokens() for row in row_list] def _wrap_array(value: Any) -> xr.DataArray: @@ -121,8 +117,6 @@ def _unstructure_package(value: Package) -> dict[str, Any]: blocks: dict[str, dict[str, Any]] = {} # Block names that must appear in output even when empty (e.g. SSM SOURCES). always_emit_set: set[str] = set() - # OC-style period fields: {field_key: {kper: setting}} (including "" stop sentinels) - oc_per_field: dict[str, dict[int, str]] = {} # Stress-period recarray fields: {kper: [(cellid, val, ...), ...]} spd_period: dict[int, list[tuple]] = {} # READARRAY period fields (G/A variants): {kper: {field_name: xr.DataArray}} @@ -177,37 +171,19 @@ def _unstructure_package(value: Package) -> dict[str, Any]: da = xr.DataArray(layer_slice) readarray_period.setdefault(kper, {})[f.name] = da continue - if isinstance(field_value, (list, tuple)) and meta.get("oc_action"): - field_value = {0: field_value} if not isinstance(field_value, dict): continue - if meta.get("oc_action"): - # OC-style: collect per-field settings (including "" stop sentinels). - # Processing is deferred to after all fields are collected so that - # fill-forward state can be computed correctly when stop sentinels - # cancel one field but other fields should continue. - action = meta["oc_action"].lower() - rtype = meta["oc_rtype"].lower() - field_key = f"{action} {rtype}" - for kper_raw, setting in field_value.items(): - kper_int = _normalize_kper(kper_raw) - if kper_int is None: - continue - if isinstance(setting, (list, tuple)): - setting = " ".join(str(s) for s in setting) - oc_per_field.setdefault(field_key, {})[kper_int] = setting - else: - # Stress-period Row list: dict[int, list[Row]] - for kper, row_list in field_value.items(): - kper_int = _normalize_kper(kper) - if kper_int is None: - continue - rows = ( - _rows_to_tuples(row_list) - if isinstance(row_list, list) and row_list and isinstance(row_list[0], Row) - else [] - ) - spd_period.setdefault(kper_int, []).extend(rows) + # Stress-period Item list: dict[int, list[Item]] + for kper, row_list in field_value.items(): + kper_int = _normalize_kper(kper) + if kper_int is None: + continue + rows = ( + _rows_to_tuples(row_list) + if isinstance(row_list, list) and row_list and isinstance(row_list[0], Item) + else [] + ) + spd_period.setdefault(kper_int, []).extend(rows) continue # ── Non-period blocks ─────────────────────────────────────────────────── @@ -218,12 +194,12 @@ def _unstructure_package(value: Package) -> dict[str, Any]: if field_value: blocks[block_name][f.name] = field_value - elif meta.get("inout") and isinstance(field_value, Path): - t = _path_to_tuple(f.name, field_value, meta.get("inout", "fileout")) + elif meta.get("direction") and isinstance(field_value, Path): + t = _path_to_tuple(f.name, field_value, meta.get("direction", "out")) blocks[block_name][t[0].lower()] = t - elif isinstance(field_value, list) and field_value and isinstance(field_value[0], Row): - # packagedata / connectiondata / etc. -- list[RowClass] block + elif isinstance(field_value, list) and field_value and isinstance(field_value[0], Item): + # packagedata / connectiondata / etc. -- list[ItemClass] block blocks[block_name][f.name] = _rows_to_tuples(field_value) elif isinstance(field_value, list) and field_value and isinstance(field_value[0], tuple): @@ -255,7 +231,7 @@ def _unstructure_package(value: Package) -> dict[str, Any]: elif f.name == "auxiliary" and isinstance(field_value, list): blocks[block_name][f.name] = ("AUXILIARY",) + tuple(field_value) - elif attrs.has(type(field_value)) and "_keyword" in vars(type(field_value)): + elif isinstance(field_value, Record): # Inner-class record (e.g. Oc.Headprint) blocks[block_name][f.name] = field_value.to_tokens() @@ -267,55 +243,10 @@ def _unstructure_package(value: Package) -> dict[str, Any]: elif dfn_type == "string" and field_value: blocks[block_name][f.name] = field_value - # All kpers where any OC field has an explicit setting (including "" stop sentinels). - oc_explicit_kpers: set[int] = set() - for fk_settings in oc_per_field.values(): - oc_explicit_kpers.update(fk_settings.keys()) - - # Build oc_period: for each explicit kper include fields with an explicit - # non-empty setting. "" is a stop sentinel that cancels that field's - # fill-forward. When a kper has any stop sentinel we must emit a PERIOD - # block; include fill-forward values for still-active non-stopped fields so - # the emitted block doesn't silently reset them in MF6. - oc_period: dict[int, dict[str, str]] = {} - oc_is_stop: set[int] = set() # kpers that have at least one stop sentinel - ff_state: dict[str, str] = {} # currently active fill-forward values - for kper in sorted(oc_explicit_kpers): - block_oc: dict[str, str] = {} - stopped_fields: set[str] = set() - for field_key, fk_settings in oc_per_field.items(): - if kper not in fk_settings: - continue - v = fk_settings[kper] - if not v: - oc_is_stop.add(kper) - stopped_fields.add(field_key) - else: - block_oc[field_key] = v - ff_state[field_key] = v - if kper in oc_is_stop: - # Include fill-forward values for fields that are still active so - # the required PERIOD block doesn't reset them in MF6. - for field_key, ff_val in list(ff_state.items()): - if field_key not in stopped_fields and field_key not in block_oc: - block_oc[field_key] = ff_val - for field_key in stopped_fields: - ff_state.pop(field_key, None) - oc_period[kper] = block_oc - - # Assemble period blocks: OC scalar fields + recarray rows, in kper order - all_kpers = set(oc_period.keys()) | set(spd_period.keys()) - for kper in sorted(all_kpers): + # Assemble period blocks (stress-period Item rows), in kper order. + for kper in sorted(spd_period.keys()): key = f"period {kper + 1}" - block: dict[str, Any] = {} - if kper in oc_period: - block.update(oc_period[kper]) - if kper in spd_period: - block["period"] = spd_period[kper] - if block or kper in oc_is_stop: - blocks[key] = block - if kper in oc_is_stop and not block: - always_emit_set.add(key) + blocks[key] = {"period": spd_period[kper]} # READARRAY period blocks (G/A variants): each kper gets its own period block. # Fields where every value is FILL_DNODATA are skipped; if no fields remain @@ -369,8 +300,7 @@ def _unstructure_component(value: Component) -> dict[str, Any]: raw_value = getattr(value, field_name, None) if raw_value is None: continue - cls = type(raw_value) - if attrs.has(cls) and "_keyword" in vars(cls): + if isinstance(raw_value, Record): blocks[block_name][field_name] = raw_value.to_tokens() continue @@ -385,7 +315,7 @@ def _unstructure_component(value: Component) -> dict[str, Any]: field_spec = xatspec.attrs[field_name] field_meta = getattr(field_spec, "metadata", {}) t = _path_to_tuple( - field_name, field_value, inout=field_meta.get("inout", "fileout") + field_name, field_value, direction=field_meta.get("direction", "out") ) blocks[block_name][t[0]] = t case datetime(): diff --git a/flopy4/mf6/converter/ingress/structure.py b/flopy4/mf6/converter/ingress/structure.py index bb1fe413..df402984 100644 --- a/flopy4/mf6/converter/ingress/structure.py +++ b/flopy4/mf6/converter/ingress/structure.py @@ -9,8 +9,8 @@ from flopy4.dimensions import DimensionProvider from flopy4.mf6.component import Component, get_ftype from flopy4.mf6.constants import FILL_DNODATA +from flopy4.mf6.item import Item, infer_ncelldim, item_list_type, parse_union_items from flopy4.mf6.package import Package -from flopy4.mf6.row import Row, infer_ncelldim, parse_union_rows, row_list_type from flopy4.mf6.spec import to_field_type @@ -29,28 +29,26 @@ def _inner_class_type(field_type) -> type | None: def _parse_rows( rows: list, - row_cls: "type[Row] | tuple[type[Row], ...]", + item_cls: "type[Item] | tuple[type[Item], ...]", *, naux: int = 0, boundnames: bool = False, ) -> list | None: - """Parse raw token rows into a list of Row instances. - - row_cls is either a single Row class (its own fields, with cellid=/pk=/ - fk=/time_series= metadata, are the schema -- see Row.from_row) or a - tuple of arm classes for a keystring union field, dispatched per-row by - keyword token (see row.parse_union_rows). ncelldim (a variable-width - cellid's element count) is inferred once from the first row, same as - the old Schema-driven parser did -- not applicable to unions (arms with - a cellid field aren't a case seen in the corpus). + """Parse raw token rows into a list of Item instances. + + item_cls is either a single Item class or a tuple of arm classes for a + keystring union field, dispatched per-row by keyword token (see + item.parse_union_items). ncelldim (a variable-width cellid's element + count) is inferred once from the first row -- not applicable to unions + (arms with a cellid field aren't a case seen in the corpus). """ if not rows: return None - if isinstance(row_cls, tuple): - return parse_union_rows(rows, row_cls, naux=naux, boundnames=boundnames) - ncelldim = infer_ncelldim(rows, row_cls, naux=naux) + if isinstance(item_cls, tuple): + return parse_union_items(rows, item_cls, naux=naux, boundnames=boundnames) + ncelldim = infer_ncelldim(rows, item_cls, naux=naux) result = [ - row_cls.from_row(row, ncelldim=ncelldim, naux=naux, boundnames=boundnames) + item_cls.from_tokens(row, ncelldim=ncelldim, naux=naux, boundnames=boundnames) for row in rows if row ] @@ -209,8 +207,8 @@ def _apply_binding_terms(child: Any, terms: list) -> None: exchange couples, or the model name(s) a solution applies to) that isn't recoverable from the referenced file's own content -- write it back onto the loaded child. A `Model`/`Package` target's trailing term - is just its pname (already handled by xattree's own naming), not state - to set here. + is just its pname, handled by the caller (`_resolve_bindings`) via + `Component.pname`, not state to set here. """ from flopy4.mf6.exchange import Exchange from flopy4.mf6.solution import Solution @@ -348,16 +346,18 @@ def _resolve_bindings(cls: type, raw_lower: dict, workspace: Path) -> dict[str, # (coupled model names / applicable models), not a name to # assign the loaded child itself. # - # name= only actually takes effect for "dict"-kind children - # below (Simulation.models/exchanges/solutions) -- xattree - # reconciles a "list"-kind child's name to f"{field}{index}" - # and an "only"-kind child's to the field name regardless of - # what's passed (confirmed both at load time here and at write - # time: Chd(name="custom")/Ic(name="custom") get renamed - # "chd0"/"ic" the same way on construction already, before - # this code ever runs). Passed through anyway for the dict - # case and because it's harmless (silently ignored) otherwise, - # not because it's expected to matter for "list"/"only". + # name= (xattree's own attribute) only actually takes effect + # for "dict"-kind children below (Simulation.models/exchanges/ + # solutions) -- xattree reconciles a "list"-kind child's name + # to f"{field}{index}" and an "only"-kind child's to the field + # name regardless of what's passed (confirmed both at load + # time here and at write time: Chd(name="custom")/ + # Ic(name="custom") get renamed "chd0"/"ic" the same way on + # construction already, before this code ever runs). Passed + # through anyway for the dict case and because it's harmless + # (silently ignored) otherwise. The real pname for "list"/ + # "only"-kind children is instead preserved via the plain, + # xattree-unmanaged Component.pname field, set below. pname = ( str(row[2]) if len(row) > 2 and not issubclass(target_cls, (Exchange, Solution)) @@ -369,6 +369,12 @@ def _resolve_bindings(cls: type, raw_lower: dict, workspace: Path) -> dict[str, else target_cls.load(workspace / fname, name=pname) ) child.filename = fname + if pname: + # Plain, xattree-unmanaged field (see Component.pname) -- + # preserves the row's real pname for "list"/"only"-kind + # children even though xattree itself reconciles .name to + # a field-derived value regardless of what's passed above. + child.pname = pname _apply_binding_terms(child, row[2:]) if isinstance(child, DimensionProvider): dims = {**dims, **child.get_dims()} @@ -453,37 +459,30 @@ def structure_component( if kw: inner_class_fields[kw.lower()] = (f, inner_cls) - # Identify Row-list fields (packagedata, connectiondata, partitions …) -- - # the field's own type annotation (Optional[list[RowClass]] or - # Optional[dict[int, list[RowClass]]]) is the schema; no separate - # Schema/Column lookup. - block_row_fields: dict[str, tuple] = {} # block_name → (field, row_cls) - oc_fields: list = [] # fields with oc_action metadata - period_field = None # field for the period Row-list - period_row_cls: "type[Row] | tuple[type[Row], ...] | None" = None + # Identify Item-list fields (packagedata, connectiondata, partitions …) -- + # the field's own type annotation (Optional[list[ItemClass]] or + # Optional[dict[int, list[ItemClass]]]) is the schema. + block_item_fields: dict[str, tuple] = {} # block_name → (field, item_cls) + period_field = None # field for the period Item-list + period_item_cls: "type[Item] | tuple[type[Item], ...] | None" = None for f in attrs.fields(cls): block = f.metadata.get("block", "") - oc_action = f.metadata.get("oc_action") - - if oc_action: - oc_fields.append(f) - continue - row_cls = row_list_type(f.type) - if row_cls is None: + item_cls = item_list_type(f.type) + if item_cls is None: continue if block == "period": period_field = f - period_row_cls = row_cls + period_item_cls = item_cls else: - block_row_fields[block] = (f, row_cls) + block_item_fields[block] = (f, item_cls) # ── Pass 1: scalar blocks (options, dimensions, etc.) ──────────────────── kwargs: dict[str, Any] = {} for block_name, rows in raw_lower.items(): if not rows: continue - if block_name in block_row_fields or block_name.startswith("period"): + if block_name in block_item_fields or block_name.startswith("period"): continue for row in rows: if not row: @@ -514,12 +513,12 @@ def structure_component( naux = len(aux_opt) if isinstance(aux_opt, list) else 1 boundnames = bool(kwargs.get("boundnames", False)) - # ── Pass 2: block Row-list fields (packagedata, partitions …) ─────────── - for block_name, (f, row_cls) in block_row_fields.items(): + # ── Pass 2: block Item-list fields (packagedata, partitions …) ────────── + for block_name, (f, item_cls) in block_item_fields.items(): rows = raw_lower.get(block_name, []) if not rows: continue - row_list = _parse_rows(rows, row_cls, naux=naux, boundnames=boundnames) + row_list = _parse_rows(rows, item_cls, naux=naux, boundnames=boundnames) if row_list is not None: init_key = f.alias if (f.alias and not f.alias.startswith("_")) else f.name kwargs[init_key] = row_list @@ -539,35 +538,13 @@ def structure_component( kper_rows[kper] = rows if kper_rows: - if oc_fields: - # OC-style: rows like [ACTION, RTYPE, SETTING …] - # Map (action, rtype) → field name - oc_map: dict[tuple[str, str], str] = {} - for f in oc_fields: - action = f.metadata["oc_action"].lower() - rtype = f.metadata["oc_rtype"].lower() - oc_map[(action, rtype)] = f.alias if f.alias else f.name - - collected: dict[str, dict[int, str]] = {} - for kper, rows in sorted(kper_rows.items()): - for row in rows: - if len(row) < 2: - continue - action = str(row[0]).lower() - rtype = str(row[1]).lower() - field_key = oc_map.get((action, rtype)) - if field_key: - setting = " ".join(str(t) for t in row[2:]) if len(row) > 2 else "all" - collected.setdefault(field_key, {})[kper] = setting - kwargs.update(collected) - - elif period_field is not None: - assert period_row_cls is not None # set together with period_field above + if period_field is not None: + assert period_item_cls is not None # set together with period_field above spd: dict[int, list] = {} for kper, rows in sorted(kper_rows.items()): if not rows: continue - row_list = _parse_rows(rows, period_row_cls, naux=naux, boundnames=boundnames) + row_list = _parse_rows(rows, period_item_cls, naux=naux, boundnames=boundnames) if row_list is not None: spd[kper] = row_list if spd: diff --git a/flopy4/mf6/exg/gwfgwe.py b/flopy4/mf6/exg/gwfgwe.py index 75c91385..f1415b11 100644 --- a/flopy4/mf6/exg/gwfgwe.py +++ b/flopy4/mf6/exg/gwfgwe.py @@ -1,4 +1,6 @@ # autogenerated file, do not modify +from typing import ClassVar + import attrs from flopy4.mf6.package import Package @@ -6,4 +8,4 @@ @attrs.define(kw_only=True, slots=False) class Gwfgwe(Package): - pass + dfn_name: ClassVar[str] = "exg-gwfgwe" diff --git a/flopy4/mf6/exg/gwfgwt.py b/flopy4/mf6/exg/gwfgwt.py index ad3342cb..a4156ac6 100644 --- a/flopy4/mf6/exg/gwfgwt.py +++ b/flopy4/mf6/exg/gwfgwt.py @@ -1,4 +1,6 @@ # autogenerated file, do not modify +from typing import ClassVar + import attrs from flopy4.mf6.package import Package @@ -6,4 +8,4 @@ @attrs.define(kw_only=True, slots=False) class Gwfgwt(Package): - pass + dfn_name: ClassVar[str] = "exg-gwfgwt" diff --git a/flopy4/mf6/exg/gwfprt.py b/flopy4/mf6/exg/gwfprt.py index b4622f26..d2de62e5 100644 --- a/flopy4/mf6/exg/gwfprt.py +++ b/flopy4/mf6/exg/gwfprt.py @@ -1,4 +1,6 @@ # autogenerated file, do not modify +from typing import ClassVar + import attrs from flopy4.mf6.package import Package @@ -6,4 +8,4 @@ @attrs.define(kw_only=True, slots=False) class Gwfprt(Package): - pass + dfn_name: ClassVar[str] = "exg-gwfprt" diff --git a/flopy4/mf6/gwe/__init__.py b/flopy4/mf6/gwe/__init__.py index e770373e..4e1b2543 100644 --- a/flopy4/mf6/gwe/__init__.py +++ b/flopy4/mf6/gwe/__init__.py @@ -61,13 +61,13 @@ class Gwe(Model): save_flows: bool = field(block="options", default=False) dependent_variable_scaling: bool = field(block="options", default=False) netcdf_mesh2d_file: Optional[Path] = path( - block="options", default=None, converter=to_path, inout="fileout" + block="options", default=None, converter=to_path, direction="out" ) netcdf_structured_file: Optional[Path] = path( - block="options", default=None, converter=to_path, inout="fileout" + block="options", default=None, converter=to_path, direction="out" ) netcdf_input_file: Optional[Path] = path( - block="options", default=None, converter=to_path, inout="filein" + block="options", default=None, converter=to_path, direction="in" ) dis: DisBase | None = field(converter=convert_grid, block="packages", default=None) ic: Ic | None = field(block="packages", default=None) diff --git a/flopy4/mf6/gwe/ctp.py b/flopy4/mf6/gwe/ctp.py index d0d0511c..c8ebd048 100644 --- a/flopy4/mf6/gwe/ctp.py +++ b/flopy4/mf6/gwe/ctp.py @@ -5,12 +5,10 @@ import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path -_Row = Row - @attrs.define(kw_only=True, slots=False) class Ctp(Package): @@ -19,7 +17,7 @@ class Ctp(Package): multi_package: ClassVar[bool] = True @attrs.define - class Row(_Row): + class StressPeriodData(Item): cellid: tuple = field(cellid=True) temp: Union[float, str] = field(time_series=True) aux: tuple = () @@ -60,21 +58,21 @@ class Row(_Row): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) obs_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) maxbound: Optional[int] = field( default=0, block="dimensions", auto_from="stress_period_data", ) - _stress_period_data: Optional[dict[int, list[Row]]] = field( + _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( alias="stress_period_data", default=None, repr=False, @@ -83,4 +81,4 @@ class Row(_Row): ) -CtpRow = Ctp.Row +CtpStressPeriodData = Ctp.StressPeriodData diff --git a/flopy4/mf6/gwe/dis.py b/flopy4/mf6/gwe/dis.py index 4e1c459c..2e08099e 100644 --- a/flopy4/mf6/gwe/dis.py +++ b/flopy4/mf6/gwe/dis.py @@ -28,7 +28,7 @@ class Dis(DisBase): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) ncf: Optional[Ncf] = attrs.field(default=None) nlay: int = field(default=1, block="dimensions") diff --git a/flopy4/mf6/gwe/disv.py b/flopy4/mf6/gwe/disv.py index 80bd5170..2750e660 100644 --- a/flopy4/mf6/gwe/disv.py +++ b/flopy4/mf6/gwe/disv.py @@ -7,7 +7,7 @@ from flopy4.mf6._types import _optional_path from flopy4.mf6.gwf.disbase import DisBase -from flopy4.mf6.row import Row +from flopy4.mf6.item import Item from flopy4.mf6.spec import field, path from flopy4.mf6.utils.grid import VertexGrid from flopy4.mf6.utl.ncf import Ncf @@ -26,7 +26,7 @@ class Cell2dRecord: icvert: tuple[int, ...] = attrs.field() @attrs.define - class VerticesRow(Row): + class Vertices(Item): iv: int xv: float yv: float @@ -43,7 +43,7 @@ class VerticesRow(Row): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) ncf: Optional[Ncf] = attrs.field(default=None) nlay: int = field(default=0, block="dimensions") @@ -73,7 +73,7 @@ class VerticesRow(Row): iv: Optional[NDArray[np.int64]] = attrs.field(default=None) xv: Optional[NDArray[np.float64]] = attrs.field(default=None) yv: Optional[NDArray[np.float64]] = attrs.field(default=None) - vertices: Optional[list[VerticesRow]] = field(default=None, block="vertices") + vertices: Optional[list[Vertices]] = field(default=None, block="vertices") cell2ddata: Optional[list] = attrs.field(default=None) cell2d: Optional[list] = field(default=None, init=False, block="cell2d") @@ -86,7 +86,7 @@ def __attrs_post_init__(self): object.__setattr__(self, "yv", np.asarray(self.yv, dtype=np.float64)) if self.iv is not None and self.xv is not None and (self.yv is not None): rows = [ - self.VerticesRow(iv=int(iv) + 1, xv=float(xv), yv=float(yv)) + self.Vertices(iv=int(iv) + 1, xv=float(xv), yv=float(yv)) for iv, xv, yv in zip(self.iv, self.xv, self.yv) ] object.__setattr__(self, "vertices", rows) diff --git a/flopy4/mf6/gwe/esl.py b/flopy4/mf6/gwe/esl.py index 80d8455c..b876376f 100644 --- a/flopy4/mf6/gwe/esl.py +++ b/flopy4/mf6/gwe/esl.py @@ -5,12 +5,10 @@ import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path -_Row = Row - @attrs.define(kw_only=True, slots=False) class Esl(Package): @@ -19,7 +17,7 @@ class Esl(Package): multi_package: ClassVar[bool] = True @attrs.define - class Row(_Row): + class StressPeriodData(Item): cellid: tuple = field(cellid=True) senerrate: Union[float, str] = field(time_series=True) aux: tuple = () @@ -60,21 +58,21 @@ class Row(_Row): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) obs_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) maxbound: Optional[int] = field( default=0, block="dimensions", auto_from="stress_period_data", ) - _stress_period_data: Optional[dict[int, list[Row]]] = field( + _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( alias="stress_period_data", default=None, repr=False, @@ -83,4 +81,4 @@ class Row(_Row): ) -EslRow = Esl.Row +EslStressPeriodData = Esl.StressPeriodData diff --git a/flopy4/mf6/gwe/fmi.py b/flopy4/mf6/gwe/fmi.py index 3dac42d9..578904dc 100644 --- a/flopy4/mf6/gwe/fmi.py +++ b/flopy4/mf6/gwe/fmi.py @@ -1,20 +1,22 @@ # autogenerated file, do not modify from pathlib import Path -from typing import Optional, Union +from typing import ClassVar, Optional, Union import attrs +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path @attrs.define(kw_only=True, slots=False) class Fmi(Package): + dfn_name: ClassVar[str] = "gwe-fmi" + @attrs.define - class PackagedataRow(Row): - flowtype: Union[float, str] - fname: Path = path(converter=Path, inout="filein") + class Packagedata(Item): + flowtype: Union[float, str] = field() + fname: Path = path(converter=Path, direction="in") aux: tuple = () save_flows: bool = field( @@ -27,10 +29,10 @@ class PackagedataRow(Row): block="options", optional=True, ) - packagedata: Optional[list[PackagedataRow]] = field( + packagedata: Optional[list[Packagedata]] = field( default=None, block="packagedata", ) -FmiPackagedataRow = Fmi.PackagedataRow +FmiPackagedata = Fmi.Packagedata diff --git a/flopy4/mf6/gwe/lke.py b/flopy4/mf6/gwe/lke.py index a0780cd6..54aa2616 100644 --- a/flopy4/mf6/gwe/lke.py +++ b/flopy4/mf6/gwe/lke.py @@ -1,16 +1,14 @@ # autogenerated file, do not modify from pathlib import Path -from typing import ClassVar, Optional +from typing import ClassVar, Optional, Union import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path -_Row = Row - @attrs.define(kw_only=True, slots=False) class Lke(Package): @@ -19,8 +17,8 @@ class Lke(Package): multi_package: ClassVar[bool] = True @attrs.define - class PackagedataRow(Row): - lakeno: int = field(pk=True) + class Packagedata(Item): + lakeno: int = field(index=True, pk=True) strt: float = field() ktf: float = field() rbthcnd: float = field() @@ -28,10 +26,51 @@ class PackagedataRow(Row): boundname: Optional[str] = field(default=None, optional=True) @attrs.define - class Row(_Row): - number: int = field(pk=True) - keyword: str = field() - value: Optional[object] = field(default=None, optional=True) + class Status(Item): + _keyword: ClassVar[str] = "status" + lakeno: int = field(index=True, fk="packagedata.lakeno") + status: Union[float, str] = field() + + @attrs.define + class Temperature(Item): + _keyword: ClassVar[str] = "temperature" + lakeno: int = field(index=True, fk="packagedata.lakeno") + temperature: Union[float, str] = field(time_series=True) + + @attrs.define + class Rainfall(Item): + _keyword: ClassVar[str] = "rainfall" + lakeno: int = field(index=True, fk="packagedata.lakeno") + rainfall: Union[float, str] = field(time_series=True) + + @attrs.define + class Evaporation(Item): + _keyword: ClassVar[str] = "evaporation" + lakeno: int = field(index=True, fk="packagedata.lakeno") + evaporation: Union[float, str] = field(time_series=True) + + @attrs.define + class Runoff(Item): + _keyword: ClassVar[str] = "runoff" + lakeno: int = field(index=True, fk="packagedata.lakeno") + runoff: Union[float, str] = field(time_series=True) + + @attrs.define + class ExtInflow(Item): + _keyword: ClassVar[str] = "ext-inflow" + lakeno: int = field(index=True, fk="packagedata.lakeno") + ext_inflow: Union[float, str] = field(time_series=True) + + @attrs.define + class Auxiliary(Item): + _keyword: ClassVar[str] = "auxiliary" + lakeno: int = field(index=True, fk="packagedata.lakeno") + auxname: Union[float, str] = field() + auxval: Union[float, str] = field(time_series=True) + + _StressPeriodDataItem = ( + Status | Temperature | Rainfall | Evaporation | Runoff | ExtInflow | Auxiliary + ) flow_package_name: Optional[str] = field( default=None, @@ -78,41 +117,41 @@ class Row(_Row): converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) budget_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) budgetcsv_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) ts_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) obs_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) - packagedata: Optional[list[PackagedataRow]] = field( + packagedata: Optional[list[Packagedata]] = field( default=None, block="packagedata", ) - _stress_period_data: Optional[dict[int, list[Row]]] = field( + _stress_period_data: Optional[dict[int, list[_StressPeriodDataItem]]] = field( alias="stress_period_data", default=None, repr=False, @@ -121,5 +160,11 @@ class Row(_Row): ) -LkeRow = Lke.Row -LkePackagedataRow = Lke.PackagedataRow +LkePackagedata = Lke.Packagedata +LkeStatus = Lke.Status +LkeTemperature = Lke.Temperature +LkeRainfall = Lke.Rainfall +LkeEvaporation = Lke.Evaporation +LkeRunoff = Lke.Runoff +LkeExtInflow = Lke.ExtInflow +LkeAuxiliary = Lke.Auxiliary diff --git a/flopy4/mf6/gwe/mve.py b/flopy4/mf6/gwe/mve.py index 0000f1cc..1fc01b07 100644 --- a/flopy4/mf6/gwe/mve.py +++ b/flopy4/mf6/gwe/mve.py @@ -33,12 +33,12 @@ class Mve(Package): converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) budgetcsv_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) diff --git a/flopy4/mf6/gwe/oc.py b/flopy4/mf6/gwe/oc.py index 162a6c3c..a5cf3fbc 100644 --- a/flopy4/mf6/gwe/oc.py +++ b/flopy4/mf6/gwe/oc.py @@ -1,10 +1,11 @@ # autogenerated file, do not modify from pathlib import Path -from typing import ClassVar, Optional +from typing import ClassVar, Optional, Union import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package from flopy4.mf6.record import Record from flopy4.mf6.spec import field, path @@ -15,60 +16,66 @@ class Oc(Package): dfn_name: ClassVar[str] = "gwe-oc" @attrs.define - class Temperatureprint(Record): - _keyword: ClassVar[str] = "temperature" - _extra_tokens: ClassVar[tuple[str, ...]] = ("PRINT_FORMAT",) + class Format(Record): + _keyword: ClassVar[str] = "" format_: str = attrs.field() columns: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) width: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) digits: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) + @attrs.define + class Temperatureprint(Record): + _keyword: ClassVar[str] = "temperature" + _extra_tokens: ClassVar[tuple[str, ...]] = ("PRINT_FORMAT",) + formatrecord: "Oc.Format" = attrs.field() + + @attrs.define + class Save(Item): + _keyword: ClassVar[str] = "save" + rtype: Union[float, str] = field() + ocsetting: tuple = field(default=(), array=True) + + @attrs.define + class Print(Item): + _keyword: ClassVar[str] = "print" + rtype: Union[float, str] = field() + ocsetting: tuple = field(default=(), array=True) + + _StressPeriodDataItem = Save | Print + budget_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) budgetcsv_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) temperature_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) temperatureprint: Optional[Temperatureprint] = field( default=None, block="options", ) - save_temperature: Optional[dict[int, list[str]]] = field( - default=None, - block="period", - oc_action="save", - oc_rtype="temperature", - ) - save_budget: Optional[dict[int, list[str]]] = field( - default=None, - block="period", - oc_action="save", - oc_rtype="budget", - ) - print_temperature: Optional[dict[int, list[str]]] = field( - default=None, - block="period", - oc_action="print", - oc_rtype="temperature", - ) - print_budget: Optional[dict[int, list[str]]] = field( + _stress_period_data: Optional[dict[int, list[_StressPeriodDataItem]]] = field( + alias="stress_period_data", default=None, + repr=False, block="period", - oc_action="print", - oc_rtype="budget", + fill_forward=True, ) + + +OcSave = Oc.Save +OcPrint = Oc.Print diff --git a/flopy4/mf6/gwe/ssm.py b/flopy4/mf6/gwe/ssm.py index 9f1762fa..117453b2 100644 --- a/flopy4/mf6/gwe/ssm.py +++ b/flopy4/mf6/gwe/ssm.py @@ -4,8 +4,8 @@ import attrs +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path @@ -14,15 +14,15 @@ class Ssm(Package): dfn_name: ClassVar[str] = "gwe-ssm" @attrs.define - class SourcesRow(Row): - pname: Union[float, str] - srctype: Union[float, str] - auxname: Union[float, str] + class Sources(Item): + pname: Union[float, str] = field() + srctype: Union[float, str] = field() + auxname: Union[float, str] = field() @attrs.define - class FileinputRow(Row): - pname: Union[float, str] - spc6_filename: Path = path(converter=Path, inout="filein", prefix=("SPC6",)) + class Fileinput(Item): + pname: Union[float, str] = field() + spc6_filename: Path = path(converter=Path, direction="in", prefix=("SPC6",)) mixed: Optional[str] = field(default=None, tagged=True, optional=True) print_flows: bool = field( @@ -35,16 +35,16 @@ class FileinputRow(Row): block="options", optional=True, ) - sources: Optional[list[SourcesRow]] = field( + sources: Optional[list[Sources]] = field( default=None, block="sources", always_emit=True, ) - fileinput: Optional[list[FileinputRow]] = field( + fileinput: Optional[list[Fileinput]] = field( default=None, block="fileinput", ) -SsmSourcesRow = Ssm.SourcesRow -SsmFileinputRow = Ssm.FileinputRow +SsmSources = Ssm.Sources +SsmFileinput = Ssm.Fileinput diff --git a/flopy4/mf6/gwf/__init__.py b/flopy4/mf6/gwf/__init__.py index 1a688b1f..9af628c0 100644 --- a/flopy4/mf6/gwf/__init__.py +++ b/flopy4/mf6/gwf/__init__.py @@ -155,13 +155,13 @@ def budget(self) -> xr.Dataset | xu.UgridDataset: newton: bool = field(block="options", default=False) newtonoptions: Optional[NewtonOptions] = field(block="options", default=None) netcdf_mesh2d_file: Optional[Path] = path( - block="options", default=None, converter=to_path, inout="fileout" + block="options", default=None, converter=to_path, direction="out" ) netcdf_structured_file: Optional[Path] = path( - block="options", default=None, converter=to_path, inout="fileout" + block="options", default=None, converter=to_path, direction="out" ) netcdf_input_file: Optional[Path] = path( - block="options", default=None, converter=to_path, inout="filein" + block="options", default=None, converter=to_path, direction="in" ) dis: DisBase | None = field(converter=convert_grid, block="packages", default=None) ic: Ic | None = field(block="packages", default=None) diff --git a/flopy4/mf6/gwf/api.py b/flopy4/mf6/gwf/api.py index 05c7fb57..396b357d 100644 --- a/flopy4/mf6/gwf/api.py +++ b/flopy4/mf6/gwf/api.py @@ -11,6 +11,8 @@ @attrs.define(kw_only=True, slots=False) class Api(Package): + dfn_name: ClassVar[str] = "gwf-api" + multi_package: ClassVar[bool] = True boundnames: bool = field( @@ -38,7 +40,7 @@ class Api(Package): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) mover: bool = field( default=False, diff --git a/flopy4/mf6/gwf/buy.py b/flopy4/mf6/gwf/buy.py index f053af0e..b7998c1e 100644 --- a/flopy4/mf6/gwf/buy.py +++ b/flopy4/mf6/gwf/buy.py @@ -5,8 +5,8 @@ import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path @@ -15,8 +15,8 @@ class Buy(Package): dfn_name: ClassVar[str] = "gwf-buy" @attrs.define - class PackagedataRow(Row): - irhospec: int = field(pk=True) + class Packagedata(Item): + irhospec: int = field(index=True, pk=True) drhodc: float = field() crhoref: float = field() modelname: Union[float, str] = field() @@ -38,17 +38,17 @@ class PackagedataRow(Row): converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) nrhospecies: Optional[int] = field( default=None, block="dimensions", ) - packagedata: Optional[list[PackagedataRow]] = field( + packagedata: Optional[list[Packagedata]] = field( default=None, block="packagedata", auto_from="packagedata", ) -BuyPackagedataRow = Buy.PackagedataRow +BuyPackagedata = Buy.Packagedata diff --git a/flopy4/mf6/gwf/chd.py b/flopy4/mf6/gwf/chd.py index 9daef588..18daa04d 100644 --- a/flopy4/mf6/gwf/chd.py +++ b/flopy4/mf6/gwf/chd.py @@ -5,12 +5,10 @@ import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path -_Row = Row - @attrs.define(kw_only=True, slots=False) class Chd(Package): @@ -19,7 +17,7 @@ class Chd(Package): multi_package: ClassVar[bool] = True @attrs.define - class Row(_Row): + class StressPeriodData(Item): cellid: tuple = field(cellid=True) head: Union[float, str] = field(time_series=True) aux: tuple = () @@ -60,21 +58,21 @@ class Row(_Row): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) obs_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) maxbound: Optional[int] = field( default=0, block="dimensions", auto_from="stress_period_data", ) - _stress_period_data: Optional[dict[int, list[Row]]] = field( + _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( alias="stress_period_data", default=None, repr=False, @@ -83,4 +81,4 @@ class Row(_Row): ) -ChdRow = Chd.Row +ChdStressPeriodData = Chd.StressPeriodData diff --git a/flopy4/mf6/gwf/chdg.py b/flopy4/mf6/gwf/chdg.py index 51a817e6..6bd18373 100644 --- a/flopy4/mf6/gwf/chdg.py +++ b/flopy4/mf6/gwf/chdg.py @@ -49,7 +49,7 @@ class Chdg(Package): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) export_array_netcdf: bool = field( default=False, diff --git a/flopy4/mf6/gwf/csub.py b/flopy4/mf6/gwf/csub.py index 68d3bbfe..c65e0601 100644 --- a/flopy4/mf6/gwf/csub.py +++ b/flopy4/mf6/gwf/csub.py @@ -5,20 +5,18 @@ import attrs from flopy4.mf6._types import FloatArrayLike, _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path -_Row = Row - @attrs.define(kw_only=True, slots=False) class Csub(Package): dfn_name: ClassVar[str] = "gwf-csub" @attrs.define - class PackagedataRow(Row): - icsubno: int = field(pk=True) + class Packagedata(Item): + icsubno: int = field(index=True, pk=True) cellid: tuple = field(cellid=True) cdelay: Union[float, str] = field() pcs0: float = field() @@ -33,7 +31,7 @@ class PackagedataRow(Row): boundname: Optional[str] = field(default=None, optional=True) @attrs.define - class Row(_Row): + class StressPeriodData(Item): cellid: tuple = field(cellid=True) sig0: Union[float, str] = field(time_series=True) aux: tuple = () @@ -63,6 +61,16 @@ class Row(_Row): block="options", optional=True, ) + elastic_inelastic_smoothing: bool = field( + default=False, + block="options", + optional=True, + ) + strict_effective_stress: bool = field( + default=False, + block="options", + optional=True, + ) head_based: bool = field( default=False, block="options", @@ -118,77 +126,77 @@ class Row(_Row): converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) straincg_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) compaction_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) compaction_elastic_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) compaction_inelastic_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) compaction_interbed_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) compaction_coarse_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) zdisplacement_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) package_convergence_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) ts_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) obs_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) ninterbeds: Optional[int] = field( default=None, @@ -199,7 +207,7 @@ class Row(_Row): block="dimensions", optional=True, ) - packagedata: Optional[list[PackagedataRow]] = field( + packagedata: Optional[list[Packagedata]] = field( default=None, block="packagedata", auto_from="packagedata", @@ -230,7 +238,7 @@ class Row(_Row): netcdf=True, optional=True, ) - _stress_period_data: Optional[dict[int, list[Row]]] = field( + _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( alias="stress_period_data", default=None, repr=False, @@ -239,5 +247,5 @@ class Row(_Row): ) -CsubRow = Csub.Row -CsubPackagedataRow = Csub.PackagedataRow +CsubStressPeriodData = Csub.StressPeriodData +CsubPackagedata = Csub.Packagedata diff --git a/flopy4/mf6/gwf/dis.py b/flopy4/mf6/gwf/dis.py index 12027752..c8a3d45c 100644 --- a/flopy4/mf6/gwf/dis.py +++ b/flopy4/mf6/gwf/dis.py @@ -28,7 +28,7 @@ class Dis(DisBase): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) ncf: Optional[Ncf] = attrs.field(default=None) nlay: int = field(default=1, block="dimensions") diff --git a/flopy4/mf6/gwf/disv.py b/flopy4/mf6/gwf/disv.py index 27b25fc1..0e261682 100644 --- a/flopy4/mf6/gwf/disv.py +++ b/flopy4/mf6/gwf/disv.py @@ -7,7 +7,7 @@ from flopy4.mf6._types import _optional_path from flopy4.mf6.gwf.disbase import DisBase -from flopy4.mf6.row import Row +from flopy4.mf6.item import Item from flopy4.mf6.spec import field, path from flopy4.mf6.utils.grid import VertexGrid from flopy4.mf6.utl.ncf import Ncf @@ -26,7 +26,7 @@ class Cell2dRecord: icvert: tuple[int, ...] = attrs.field() @attrs.define - class VerticesRow(Row): + class Vertices(Item): iv: int xv: float yv: float @@ -43,7 +43,7 @@ class VerticesRow(Row): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) ncf: Optional[Ncf] = attrs.field(default=None) nlay: int = field(default=0, block="dimensions") @@ -73,7 +73,7 @@ class VerticesRow(Row): iv: Optional[NDArray[np.int64]] = attrs.field(default=None) xv: Optional[NDArray[np.float64]] = attrs.field(default=None) yv: Optional[NDArray[np.float64]] = attrs.field(default=None) - vertices: Optional[list[VerticesRow]] = field(default=None, block="vertices") + vertices: Optional[list[Vertices]] = field(default=None, block="vertices") cell2ddata: Optional[list] = attrs.field(default=None) cell2d: Optional[list] = field(default=None, init=False, block="cell2d") @@ -86,7 +86,7 @@ def __attrs_post_init__(self): object.__setattr__(self, "yv", np.asarray(self.yv, dtype=np.float64)) if self.iv is not None and self.xv is not None and (self.yv is not None): rows = [ - self.VerticesRow(iv=int(iv) + 1, xv=float(xv), yv=float(yv)) + self.Vertices(iv=int(iv) + 1, xv=float(xv), yv=float(yv)) for iv, xv, yv in zip(self.iv, self.xv, self.yv) ] object.__setattr__(self, "vertices", rows) diff --git a/flopy4/mf6/gwf/drn.py b/flopy4/mf6/gwf/drn.py index af08f092..dcbf0ae8 100644 --- a/flopy4/mf6/gwf/drn.py +++ b/flopy4/mf6/gwf/drn.py @@ -5,12 +5,10 @@ import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path -_Row = Row - @attrs.define(kw_only=True, slots=False) class Drn(Package): @@ -19,7 +17,7 @@ class Drn(Package): multi_package: ClassVar[bool] = True @attrs.define - class Row(_Row): + class StressPeriodData(Item): cellid: tuple = field(cellid=True) elev: Union[float, str] = field(time_series=True) cond: Union[float, str] = field(time_series=True) @@ -66,14 +64,14 @@ class Row(_Row): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) obs_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) mover: bool = field( default=False, @@ -85,7 +83,7 @@ class Row(_Row): block="dimensions", auto_from="stress_period_data", ) - _stress_period_data: Optional[dict[int, list[Row]]] = field( + _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( alias="stress_period_data", default=None, repr=False, @@ -94,4 +92,4 @@ class Row(_Row): ) -DrnRow = Drn.Row +DrnStressPeriodData = Drn.StressPeriodData diff --git a/flopy4/mf6/gwf/drng.py b/flopy4/mf6/gwf/drng.py index b5afcd3b..2ba9424e 100644 --- a/flopy4/mf6/gwf/drng.py +++ b/flopy4/mf6/gwf/drng.py @@ -54,7 +54,7 @@ class Drng(Package): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) mover: bool = field( default=False, diff --git a/flopy4/mf6/gwf/evt.py b/flopy4/mf6/gwf/evt.py index 126aba8b..b479e62d 100644 --- a/flopy4/mf6/gwf/evt.py +++ b/flopy4/mf6/gwf/evt.py @@ -5,12 +5,10 @@ import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path -_Row = Row - @attrs.define(kw_only=True, slots=False) class Evt(Package): @@ -19,7 +17,7 @@ class Evt(Package): multi_package: ClassVar[bool] = True @attrs.define - class Row(_Row): + class StressPeriodData(Item): cellid: tuple = field(cellid=True) surface: Union[float, str] = field(time_series=True) rate: Union[float, str] = field(time_series=True) @@ -70,14 +68,14 @@ class Row(_Row): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) obs_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) surf_rate_specified: bool = field( default=False, @@ -93,7 +91,7 @@ class Row(_Row): default=None, block="dimensions", ) - _stress_period_data: Optional[dict[int, list[Row]]] = field( + _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( alias="stress_period_data", default=None, repr=False, @@ -102,4 +100,4 @@ class Row(_Row): ) -EvtRow = Evt.Row +EvtStressPeriodData = Evt.StressPeriodData diff --git a/flopy4/mf6/gwf/evta.py b/flopy4/mf6/gwf/evta.py index a0400dcd..dc2b8836 100644 --- a/flopy4/mf6/gwf/evta.py +++ b/flopy4/mf6/gwf/evta.py @@ -54,14 +54,14 @@ class Evta(Package): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) obs_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) export_array_netcdf: bool = field( default=False, diff --git a/flopy4/mf6/gwf/ghb.py b/flopy4/mf6/gwf/ghb.py index cdcb9557..388e654e 100644 --- a/flopy4/mf6/gwf/ghb.py +++ b/flopy4/mf6/gwf/ghb.py @@ -5,12 +5,10 @@ import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path -_Row = Row - @attrs.define(kw_only=True, slots=False) class Ghb(Package): @@ -19,7 +17,7 @@ class Ghb(Package): multi_package: ClassVar[bool] = True @attrs.define - class Row(_Row): + class StressPeriodData(Item): cellid: tuple = field(cellid=True) bhead: Union[float, str] = field(time_series=True) cond: Union[float, str] = field(time_series=True) @@ -61,14 +59,14 @@ class Row(_Row): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) obs_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) mover: bool = field( default=False, @@ -80,7 +78,7 @@ class Row(_Row): block="dimensions", auto_from="stress_period_data", ) - _stress_period_data: Optional[dict[int, list[Row]]] = field( + _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( alias="stress_period_data", default=None, repr=False, @@ -89,4 +87,4 @@ class Row(_Row): ) -GhbRow = Ghb.Row +GhbStressPeriodData = Ghb.StressPeriodData diff --git a/flopy4/mf6/gwf/ghbg.py b/flopy4/mf6/gwf/ghbg.py index 023975d7..de9683ad 100644 --- a/flopy4/mf6/gwf/ghbg.py +++ b/flopy4/mf6/gwf/ghbg.py @@ -49,7 +49,7 @@ class Ghbg(Package): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) mover: bool = field( default=False, diff --git a/flopy4/mf6/gwf/lak.py b/flopy4/mf6/gwf/lak.py index cbc0f462..8a83b4f8 100644 --- a/flopy4/mf6/gwf/lak.py +++ b/flopy4/mf6/gwf/lak.py @@ -5,12 +5,10 @@ import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path -_Row = Row - @attrs.define(kw_only=True, slots=False) class Lak(Package): @@ -19,17 +17,17 @@ class Lak(Package): multi_package: ClassVar[bool] = True @attrs.define - class PackagedataRow(Row): - ifno: int = field(pk=True) + class Packagedata(Item): + ifno: int = field(index=True, pk=True) strt: float = field() nlakeconn: int = field() aux: tuple = () boundname: Optional[str] = field(default=None, optional=True) @attrs.define - class ConnectiondataRow(Row): - ifno: int = field(fk="packagedata.ifno") - iconn: int = field(pk=True) + class Connectiondata(Item): + ifno: int = field(index=True, fk="packagedata.ifno") + iconn: int = field(index=True, pk=True) cellid: tuple = field(cellid=True) claktype: Union[float, str] = field() bedleak: Union[float, str] = field() @@ -39,15 +37,15 @@ class ConnectiondataRow(Row): connwidth: float = field() @attrs.define - class TablesRow(Row): - ifno: int = field(fk="packagedata.ifno") - tab6_filename: Path = path(converter=Path, inout="filein", prefix=("TAB6",)) + class Tables(Item): + ifno: int = field(index=True, fk="packagedata.ifno") + tab6_filename: Path = path(converter=Path, direction="in", prefix=("TAB6",)) @attrs.define - class OutletsRow(Row): - outletno: int = field(pk=True) - lakein: int = field(fk="packagedata.ifno") - lakeout: int = field(fk="packagedata.ifno") + class Outlets(Item): + outletno: int = field(index=True, pk=True) + lakein: int = field(index=True, fk="packagedata.ifno") + lakeout: int = field(index=True, fk="packagedata.ifno") couttype: Union[float, str] = field() invert: Union[float, str] = field(time_series=True) width: Union[float, str] = field(time_series=True) @@ -55,10 +53,99 @@ class OutletsRow(Row): slope: Union[float, str] = field(time_series=True) @attrs.define - class Row(_Row): - number: int = field(pk=True) - keyword: str = field() - value: Optional[object] = field(default=None, optional=True) + class Status(Item): + _keyword: ClassVar[str] = "status" + lakeno: int = field(index=True, fk="packagedata.ifno") + status: Union[float, str] = field() + + @attrs.define + class Stage(Item): + _keyword: ClassVar[str] = "stage" + lakeno: int = field(index=True, fk="packagedata.ifno") + stage: Union[float, str] = field(time_series=True) + + @attrs.define + class Rainfall(Item): + _keyword: ClassVar[str] = "rainfall" + lakeno: int = field(index=True, fk="packagedata.ifno") + rainfall: Union[float, str] = field(time_series=True) + + @attrs.define + class Evaporation(Item): + _keyword: ClassVar[str] = "evaporation" + lakeno: int = field(index=True, fk="packagedata.ifno") + evaporation: Union[float, str] = field(time_series=True) + + @attrs.define + class Runoff(Item): + _keyword: ClassVar[str] = "runoff" + lakeno: int = field(index=True, fk="packagedata.ifno") + runoff: Union[float, str] = field(time_series=True) + + @attrs.define + class Inflow(Item): + _keyword: ClassVar[str] = "inflow" + lakeno: int = field(index=True, fk="packagedata.ifno") + inflow: Union[float, str] = field(time_series=True) + + @attrs.define + class Withdrawal(Item): + _keyword: ClassVar[str] = "withdrawal" + lakeno: int = field(index=True, fk="packagedata.ifno") + withdrawal: Union[float, str] = field(time_series=True) + + @attrs.define + class Rate(Item): + _keyword: ClassVar[str] = "rate" + outletno: int = field(index=True, fk="outlets.outletno") + rate: Union[float, str] = field(time_series=True) + + @attrs.define + class Invert(Item): + _keyword: ClassVar[str] = "invert" + outletno: int = field(index=True, fk="outlets.outletno") + invert: Union[float, str] = field(time_series=True) + + @attrs.define + class Width(Item): + _keyword: ClassVar[str] = "width" + outletno: int = field(index=True, fk="outlets.outletno") + width: Union[float, str] = field(time_series=True) + + @attrs.define + class Slope(Item): + _keyword: ClassVar[str] = "slope" + outletno: int = field(index=True, fk="outlets.outletno") + slope: Union[float, str] = field(time_series=True) + + @attrs.define + class Rough(Item): + _keyword: ClassVar[str] = "rough" + outletno: int = field(index=True, fk="outlets.outletno") + rough: Union[float, str] = field(time_series=True) + + @attrs.define + class Auxiliary(Item): + _keyword: ClassVar[str] = "auxiliary" + lakeno: int = field(index=True, fk="packagedata.ifno") + auxname: Union[float, str] = field() + auxval: Union[float, str] = field(time_series=True) + + _StressPeriodDataItem = ( + Status + | Stage + | Rainfall + | Evaporation + | Runoff + | Inflow + | Withdrawal + | Rate + | Invert + | Width + | Slope + | Rough + | Auxiliary + ) auxiliary: Optional[list[str]] = field( default=None, @@ -95,42 +182,42 @@ class Row(_Row): converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) budget_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) budgetcsv_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) package_convergence_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) ts_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) obs_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) mover: bool = field( default=False, @@ -142,6 +229,11 @@ class Row(_Row): block="options", optional=True, ) + implicit: bool = field( + default=False, + block="options", + optional=True, + ) maximum_iterations: Optional[int] = field( default=None, block="options", @@ -174,26 +266,26 @@ class Row(_Row): default=None, block="dimensions", ) - packagedata: Optional[list[PackagedataRow]] = field( + packagedata: Optional[list[Packagedata]] = field( default=None, block="packagedata", auto_from="packagedata", ) - connectiondata: Optional[list[ConnectiondataRow]] = field( + connectiondata: Optional[list[Connectiondata]] = field( default=None, block="connectiondata", ) - tables: Optional[list[TablesRow]] = field( + tables: Optional[list[Tables]] = field( default=None, block="tables", auto_from="tables", ) - outlets: Optional[list[OutletsRow]] = field( + outlets: Optional[list[Outlets]] = field( default=None, block="outlets", auto_from="outlets", ) - _stress_period_data: Optional[dict[int, list[Row]]] = field( + _stress_period_data: Optional[dict[int, list[_StressPeriodDataItem]]] = field( alias="stress_period_data", default=None, repr=False, @@ -202,8 +294,20 @@ class Row(_Row): ) -LakRow = Lak.Row -LakPackagedataRow = Lak.PackagedataRow -LakConnectiondataRow = Lak.ConnectiondataRow -LakTablesRow = Lak.TablesRow -LakOutletsRow = Lak.OutletsRow +LakPackagedata = Lak.Packagedata +LakConnectiondata = Lak.Connectiondata +LakTables = Lak.Tables +LakOutlets = Lak.Outlets +LakStatus = Lak.Status +LakStage = Lak.Stage +LakRainfall = Lak.Rainfall +LakEvaporation = Lak.Evaporation +LakRunoff = Lak.Runoff +LakInflow = Lak.Inflow +LakWithdrawal = Lak.Withdrawal +LakRate = Lak.Rate +LakInvert = Lak.Invert +LakWidth = Lak.Width +LakSlope = Lak.Slope +LakRough = Lak.Rough +LakAuxiliary = Lak.Auxiliary diff --git a/flopy4/mf6/gwf/mvr.py b/flopy4/mf6/gwf/mvr.py index 60018ca3..252f6a1a 100644 --- a/flopy4/mf6/gwf/mvr.py +++ b/flopy4/mf6/gwf/mvr.py @@ -5,30 +5,28 @@ import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path -_Row = Row - @attrs.define(kw_only=True, slots=False) class Mvr(Package): dfn_name: ClassVar[str] = "gwf-mvr" @attrs.define - class PackagesRow(Row): - pname: Union[float, str] + class Packages(Item): + pname: Union[float, str] = field() mname: Optional[Union[float, str]] = field(default=None, optional=True) @attrs.define - class Row(_Row): - pname1: Union[float, str] - id1: int - pname2: Union[float, str] - id2: int - mvrtype: Union[float, str] - value: float + class StressPeriodData(Item): + pname1: Union[float, str] = field() + id1: int = field(index=True) + pname2: Union[float, str] = field() + id2: int = field(index=True) + mvrtype: Union[float, str] = field() + value: float = field() mname1: Optional[Union[float, str]] = field(default=None, optional=True) mname2: Optional[Union[float, str]] = field(default=None, optional=True) aux: tuple = () @@ -53,14 +51,14 @@ class Row(_Row): converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) budgetcsv_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) maxmvr: Optional[int] = field( default=None, @@ -70,12 +68,12 @@ class Row(_Row): default=None, block="dimensions", ) - packages: Optional[list[PackagesRow]] = field( + packages: Optional[list[Packages]] = field( default=None, block="packages", auto_from="packages", ) - _stress_period_data: Optional[dict[int, list[Row]]] = field( + _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( alias="stress_period_data", default=None, repr=False, @@ -84,5 +82,5 @@ class Row(_Row): ) -MvrRow = Mvr.Row -MvrPackagesRow = Mvr.PackagesRow +MvrStressPeriodData = Mvr.StressPeriodData +MvrPackages = Mvr.Packages diff --git a/flopy4/mf6/gwf/npf.py b/flopy4/mf6/gwf/npf.py index a9087948..712bc110 100644 --- a/flopy4/mf6/gwf/npf.py +++ b/flopy4/mf6/gwf/npf.py @@ -98,7 +98,7 @@ class Xt3doptions(Record): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) export_array_ascii: bool = field( default=False, @@ -110,17 +110,19 @@ class Xt3doptions(Record): block="options", optional=True, ) - icelltype: IntArrayLike = field( + icelltype: Optional[IntArrayLike] = field( default=0, block="griddata", shape=("nodes",), netcdf=True, + optional=True, ) # type: ignore[assignment] - k: FloatArrayLike = field( + k: Optional[FloatArrayLike] = field( default=1.0, block="griddata", shape=("nodes",), netcdf=True, + optional=True, ) # type: ignore[assignment] k22: Optional[FloatArrayLike] = field( default=None, diff --git a/flopy4/mf6/gwf/oc.py b/flopy4/mf6/gwf/oc.py index 507d5f35..11fc6369 100644 --- a/flopy4/mf6/gwf/oc.py +++ b/flopy4/mf6/gwf/oc.py @@ -1,10 +1,11 @@ # autogenerated file, do not modify from pathlib import Path -from typing import ClassVar, Optional +from typing import ClassVar, Optional, Union import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package from flopy4.mf6.record import Record from flopy4.mf6.spec import field, path @@ -15,60 +16,66 @@ class Oc(Package): dfn_name: ClassVar[str] = "gwf-oc" @attrs.define - class Headprint(Record): - _keyword: ClassVar[str] = "head" - _extra_tokens: ClassVar[tuple[str, ...]] = ("PRINT_FORMAT",) + class Format(Record): + _keyword: ClassVar[str] = "" format_: str = attrs.field() columns: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) width: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) digits: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) + @attrs.define + class Headprint(Record): + _keyword: ClassVar[str] = "head" + _extra_tokens: ClassVar[tuple[str, ...]] = ("PRINT_FORMAT",) + formatrecord: "Oc.Format" = attrs.field() + + @attrs.define + class Save(Item): + _keyword: ClassVar[str] = "save" + rtype: Union[float, str] = field() + ocsetting: tuple = field(default=(), array=True) + + @attrs.define + class Print(Item): + _keyword: ClassVar[str] = "print" + rtype: Union[float, str] = field() + ocsetting: tuple = field(default=(), array=True) + + _StressPeriodDataItem = Save | Print + budget_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) budgetcsv_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) head_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) headprint: Optional[Headprint] = field( default=None, block="options", ) - save_head: Optional[dict[int, list[str]]] = field( - default=None, - block="period", - oc_action="save", - oc_rtype="head", - ) - save_budget: Optional[dict[int, list[str]]] = field( - default=None, - block="period", - oc_action="save", - oc_rtype="budget", - ) - print_head: Optional[dict[int, list[str]]] = field( - default=None, - block="period", - oc_action="print", - oc_rtype="head", - ) - print_budget: Optional[dict[int, list[str]]] = field( + _stress_period_data: Optional[dict[int, list[_StressPeriodDataItem]]] = field( + alias="stress_period_data", default=None, + repr=False, block="period", - oc_action="print", - oc_rtype="budget", + fill_forward=True, ) + + +OcSave = Oc.Save +OcPrint = Oc.Print diff --git a/flopy4/mf6/gwf/rch.py b/flopy4/mf6/gwf/rch.py index b7a50442..4c200b2b 100644 --- a/flopy4/mf6/gwf/rch.py +++ b/flopy4/mf6/gwf/rch.py @@ -5,12 +5,10 @@ import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path -_Row = Row - @attrs.define(kw_only=True, slots=False) class Rch(Package): @@ -19,7 +17,7 @@ class Rch(Package): multi_package: ClassVar[bool] = True @attrs.define - class Row(_Row): + class StressPeriodData(Item): cellid: tuple = field(cellid=True) recharge: Union[float, str] = field(time_series=True) aux: tuple = () @@ -65,21 +63,21 @@ class Row(_Row): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) obs_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) maxbound: Optional[int] = field( default=0, block="dimensions", auto_from="stress_period_data", ) - _stress_period_data: Optional[dict[int, list[Row]]] = field( + _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( alias="stress_period_data", default=None, repr=False, @@ -88,4 +86,4 @@ class Row(_Row): ) -RchRow = Rch.Row +RchStressPeriodData = Rch.StressPeriodData diff --git a/flopy4/mf6/gwf/rcha.py b/flopy4/mf6/gwf/rcha.py index e20be762..cbfe48c5 100644 --- a/flopy4/mf6/gwf/rcha.py +++ b/flopy4/mf6/gwf/rcha.py @@ -54,14 +54,14 @@ class Rcha(Package): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) obs_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) export_array_netcdf: bool = field( default=False, diff --git a/flopy4/mf6/gwf/riv.py b/flopy4/mf6/gwf/riv.py index faff4292..663fd4fd 100644 --- a/flopy4/mf6/gwf/riv.py +++ b/flopy4/mf6/gwf/riv.py @@ -5,12 +5,10 @@ import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path -_Row = Row - @attrs.define(kw_only=True, slots=False) class Riv(Package): @@ -19,7 +17,7 @@ class Riv(Package): multi_package: ClassVar[bool] = True @attrs.define - class Row(_Row): + class StressPeriodData(Item): cellid: tuple = field(cellid=True) stage: Union[float, str] = field(time_series=True) cond: Union[float, str] = field(time_series=True) @@ -62,14 +60,14 @@ class Row(_Row): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) obs_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) mover: bool = field( default=False, @@ -81,7 +79,7 @@ class Row(_Row): block="dimensions", auto_from="stress_period_data", ) - _stress_period_data: Optional[dict[int, list[Row]]] = field( + _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( alias="stress_period_data", default=None, repr=False, @@ -90,4 +88,4 @@ class Row(_Row): ) -RivRow = Riv.Row +RivStressPeriodData = Riv.StressPeriodData diff --git a/flopy4/mf6/gwf/rivg.py b/flopy4/mf6/gwf/rivg.py index cff47269..3bd15f12 100644 --- a/flopy4/mf6/gwf/rivg.py +++ b/flopy4/mf6/gwf/rivg.py @@ -49,7 +49,7 @@ class Rivg(Package): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) mover: bool = field( default=False, diff --git a/flopy4/mf6/gwf/sto.py b/flopy4/mf6/gwf/sto.py index 2f4ee755..76d63f56 100644 --- a/flopy4/mf6/gwf/sto.py +++ b/flopy4/mf6/gwf/sto.py @@ -5,20 +5,18 @@ import attrs from flopy4.mf6._types import FloatArrayLike, IntArrayLike, _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path -_Row = Row - @attrs.define(kw_only=True, slots=False) class Sto(Package): dfn_name: ClassVar[str] = "gwf-sto" @attrs.define - class Row(_Row): - storage: str + class StressPeriodData(Item): + storage: str = field() save_flows: bool = field( default=False, @@ -40,7 +38,7 @@ class Row(_Row): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) export_array_ascii: bool = field( default=False, @@ -70,7 +68,7 @@ class Row(_Row): shape=("nodes",), netcdf=True, ) # type: ignore[assignment] - _stress_period_data: Optional[dict[int, list[Row]]] = field( + _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( alias="stress_period_data", default=None, repr=False, @@ -79,4 +77,4 @@ class Row(_Row): ) -StoRow = Sto.Row +StoStressPeriodData = Sto.StressPeriodData diff --git a/flopy4/mf6/gwf/vsc.py b/flopy4/mf6/gwf/vsc.py index 5d97f434..f6ddb92b 100644 --- a/flopy4/mf6/gwf/vsc.py +++ b/flopy4/mf6/gwf/vsc.py @@ -5,8 +5,8 @@ import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path @@ -15,8 +15,8 @@ class Vsc(Package): dfn_name: ClassVar[str] = "gwf-vsc" @attrs.define - class PackagedataRow(Row): - iviscspec: int = field(pk=True) + class Packagedata(Item): + iviscspec: int = field(index=True, pk=True) dviscdc: float = field() cviscref: float = field() modelname: Union[float, str] = field() @@ -58,17 +58,17 @@ class PackagedataRow(Row): converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) nviscspecies: Optional[int] = field( default=None, block="dimensions", ) - packagedata: Optional[list[PackagedataRow]] = field( + packagedata: Optional[list[Packagedata]] = field( default=None, block="packagedata", auto_from="packagedata", ) -VscPackagedataRow = Vsc.PackagedataRow +VscPackagedata = Vsc.Packagedata diff --git a/flopy4/mf6/gwf/wel.py b/flopy4/mf6/gwf/wel.py index 259916a9..43153705 100644 --- a/flopy4/mf6/gwf/wel.py +++ b/flopy4/mf6/gwf/wel.py @@ -5,12 +5,10 @@ import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path -_Row = Row - @attrs.define(kw_only=True, slots=False) class Wel(Package): @@ -19,7 +17,7 @@ class Wel(Package): multi_package: ClassVar[bool] = True @attrs.define - class Row(_Row): + class StressPeriodData(Item): cellid: tuple = field(cellid=True) q: Union[float, str] = field(time_series=True) aux: tuple = () @@ -65,26 +63,31 @@ class Row(_Row): converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) flow_reduction_length: bool = field( default=False, block="options", optional=True, ) + auto_flow_reduce_auxname: Optional[str] = field( + default=None, + block="options", + optional=True, + ) ts_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) obs_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) mover: bool = field( default=False, @@ -96,7 +99,7 @@ class Row(_Row): block="dimensions", auto_from="stress_period_data", ) - _stress_period_data: Optional[dict[int, list[Row]]] = field( + _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( alias="stress_period_data", default=None, repr=False, @@ -105,4 +108,4 @@ class Row(_Row): ) -WelRow = Wel.Row +WelStressPeriodData = Wel.StressPeriodData diff --git a/flopy4/mf6/gwf/welg.py b/flopy4/mf6/gwf/welg.py index 31ef7052..1f8930ff 100644 --- a/flopy4/mf6/gwf/welg.py +++ b/flopy4/mf6/gwf/welg.py @@ -54,7 +54,7 @@ class Welg(Package): converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) flow_reduction_length: bool = field( default=False, @@ -66,7 +66,7 @@ class Welg(Package): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) mover: bool = field( default=False, diff --git a/flopy4/mf6/gwt/__init__.py b/flopy4/mf6/gwt/__init__.py index 65765d1b..0f275cd1 100644 --- a/flopy4/mf6/gwt/__init__.py +++ b/flopy4/mf6/gwt/__init__.py @@ -63,13 +63,13 @@ class Gwt(Model): save_flows: bool = field(block="options", default=False) dependent_variable_scaling: bool = field(block="options", default=False) netcdf_mesh2d_file: Optional[Path] = path( - block="options", default=None, converter=to_path, inout="fileout" + block="options", default=None, converter=to_path, direction="out" ) netcdf_structured_file: Optional[Path] = path( - block="options", default=None, converter=to_path, inout="fileout" + block="options", default=None, converter=to_path, direction="out" ) netcdf_input_file: Optional[Path] = path( - block="options", default=None, converter=to_path, inout="filein" + block="options", default=None, converter=to_path, direction="in" ) dis: DisBase | None = field(converter=convert_grid, block="packages", default=None) ic: Ic | None = field(block="packages", default=None) diff --git a/flopy4/mf6/gwt/api.py b/flopy4/mf6/gwt/api.py index 1f097f56..d0a43f1b 100644 --- a/flopy4/mf6/gwt/api.py +++ b/flopy4/mf6/gwt/api.py @@ -40,7 +40,7 @@ class Api(Package): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) mover: bool = field( default=False, diff --git a/flopy4/mf6/gwt/cnc.py b/flopy4/mf6/gwt/cnc.py index da30936d..eadb0d43 100644 --- a/flopy4/mf6/gwt/cnc.py +++ b/flopy4/mf6/gwt/cnc.py @@ -5,12 +5,10 @@ import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path -_Row = Row - @attrs.define(kw_only=True, slots=False) class Cnc(Package): @@ -19,7 +17,7 @@ class Cnc(Package): multi_package: ClassVar[bool] = True @attrs.define - class Row(_Row): + class StressPeriodData(Item): cellid: tuple = field(cellid=True) conc: Union[float, str] = field(time_series=True) aux: tuple = () @@ -60,21 +58,21 @@ class Row(_Row): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) obs_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) maxbound: Optional[int] = field( default=0, block="dimensions", auto_from="stress_period_data", ) - _stress_period_data: Optional[dict[int, list[Row]]] = field( + _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( alias="stress_period_data", default=None, repr=False, @@ -83,4 +81,4 @@ class Row(_Row): ) -CncRow = Cnc.Row +CncStressPeriodData = Cnc.StressPeriodData diff --git a/flopy4/mf6/gwt/dis.py b/flopy4/mf6/gwt/dis.py index baa4f625..eb058ec9 100644 --- a/flopy4/mf6/gwt/dis.py +++ b/flopy4/mf6/gwt/dis.py @@ -28,7 +28,7 @@ class Dis(DisBase): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) ncf: Optional[Ncf] = attrs.field(default=None) nlay: int = field(default=1, block="dimensions") diff --git a/flopy4/mf6/gwt/disv.py b/flopy4/mf6/gwt/disv.py index 64325213..654fd486 100644 --- a/flopy4/mf6/gwt/disv.py +++ b/flopy4/mf6/gwt/disv.py @@ -7,7 +7,7 @@ from flopy4.mf6._types import _optional_path from flopy4.mf6.gwf.disbase import DisBase -from flopy4.mf6.row import Row +from flopy4.mf6.item import Item from flopy4.mf6.spec import field, path from flopy4.mf6.utils.grid import VertexGrid from flopy4.mf6.utl.ncf import Ncf @@ -26,7 +26,7 @@ class Cell2dRecord: icvert: tuple[int, ...] = attrs.field() @attrs.define - class VerticesRow(Row): + class Vertices(Item): iv: int xv: float yv: float @@ -43,7 +43,7 @@ class VerticesRow(Row): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) ncf: Optional[Ncf] = attrs.field(default=None) nlay: int = field(default=0, block="dimensions") @@ -73,7 +73,7 @@ class VerticesRow(Row): iv: Optional[NDArray[np.int64]] = attrs.field(default=None) xv: Optional[NDArray[np.float64]] = attrs.field(default=None) yv: Optional[NDArray[np.float64]] = attrs.field(default=None) - vertices: Optional[list[VerticesRow]] = field(default=None, block="vertices") + vertices: Optional[list[Vertices]] = field(default=None, block="vertices") cell2ddata: Optional[list] = attrs.field(default=None) cell2d: Optional[list] = field(default=None, init=False, block="cell2d") @@ -86,7 +86,7 @@ def __attrs_post_init__(self): object.__setattr__(self, "yv", np.asarray(self.yv, dtype=np.float64)) if self.iv is not None and self.xv is not None and (self.yv is not None): rows = [ - self.VerticesRow(iv=int(iv) + 1, xv=float(xv), yv=float(yv)) + self.Vertices(iv=int(iv) + 1, xv=float(xv), yv=float(yv)) for iv, xv, yv in zip(self.iv, self.xv, self.yv) ] object.__setattr__(self, "vertices", rows) diff --git a/flopy4/mf6/gwt/fmi.py b/flopy4/mf6/gwt/fmi.py index 3dac42d9..318e0ba2 100644 --- a/flopy4/mf6/gwt/fmi.py +++ b/flopy4/mf6/gwt/fmi.py @@ -1,20 +1,22 @@ # autogenerated file, do not modify from pathlib import Path -from typing import Optional, Union +from typing import ClassVar, Optional, Union import attrs +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path @attrs.define(kw_only=True, slots=False) class Fmi(Package): + dfn_name: ClassVar[str] = "gwt-fmi" + @attrs.define - class PackagedataRow(Row): - flowtype: Union[float, str] - fname: Path = path(converter=Path, inout="filein") + class Packagedata(Item): + flowtype: Union[float, str] = field() + fname: Path = path(converter=Path, direction="in") aux: tuple = () save_flows: bool = field( @@ -27,10 +29,10 @@ class PackagedataRow(Row): block="options", optional=True, ) - packagedata: Optional[list[PackagedataRow]] = field( + packagedata: Optional[list[Packagedata]] = field( default=None, block="packagedata", ) -FmiPackagedataRow = Fmi.PackagedataRow +FmiPackagedata = Fmi.Packagedata diff --git a/flopy4/mf6/gwt/ist.py b/flopy4/mf6/gwt/ist.py index fdec8f2e..90113857 100644 --- a/flopy4/mf6/gwt/ist.py +++ b/flopy4/mf6/gwt/ist.py @@ -12,17 +12,24 @@ @attrs.define(kw_only=True, slots=False) class Ist(Package): + dfn_name: ClassVar[str] = "gwt-ist" + multi_package: ClassVar[bool] = True @attrs.define - class Cimprint(Record): - _keyword: ClassVar[str] = "cim" - _extra_tokens: ClassVar[tuple[str, ...]] = ("PRINT_FORMAT",) + class Format(Record): + _keyword: ClassVar[str] = "" format_: str = attrs.field() columns: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) width: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) digits: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) + @attrs.define + class Cimprint(Record): + _keyword: ClassVar[str] = "cim" + _extra_tokens: ClassVar[tuple[str, ...]] = ("PRINT_FORMAT",) + formatrecord: "Ist.Format" = attrs.field() + save_flows: bool = field( default=False, block="options", @@ -33,14 +40,14 @@ class Cimprint(Record): converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) budgetcsv_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) sorption: Optional[str] = field( default=None, @@ -62,7 +69,7 @@ class Cimprint(Record): converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) cimprint: Optional[Cimprint] = field( default=None, @@ -73,7 +80,7 @@ class Cimprint(Record): converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) export_array_ascii: bool = field( default=False, diff --git a/flopy4/mf6/gwt/lkt.py b/flopy4/mf6/gwt/lkt.py index 78c3062d..46364507 100644 --- a/flopy4/mf6/gwt/lkt.py +++ b/flopy4/mf6/gwt/lkt.py @@ -1,16 +1,14 @@ # autogenerated file, do not modify from pathlib import Path -from typing import ClassVar, Optional +from typing import ClassVar, Optional, Union import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path -_Row = Row - @attrs.define(kw_only=True, slots=False) class Lkt(Package): @@ -19,17 +17,58 @@ class Lkt(Package): multi_package: ClassVar[bool] = True @attrs.define - class PackagedataRow(Row): - ifno: int = field(pk=True) + class Packagedata(Item): + ifno: int = field(index=True, pk=True) strt: float = field() aux: tuple = () boundname: Optional[str] = field(default=None, optional=True) @attrs.define - class Row(_Row): - number: int = field(pk=True) - keyword: str = field() - value: Optional[object] = field(default=None, optional=True) + class Status(Item): + _keyword: ClassVar[str] = "status" + ifno: int = field(index=True, fk="packagedata.ifno") + status: Union[float, str] = field() + + @attrs.define + class Concentration(Item): + _keyword: ClassVar[str] = "concentration" + ifno: int = field(index=True, fk="packagedata.ifno") + concentration: Union[float, str] = field(time_series=True) + + @attrs.define + class Rainfall(Item): + _keyword: ClassVar[str] = "rainfall" + ifno: int = field(index=True, fk="packagedata.ifno") + rainfall: Union[float, str] = field(time_series=True) + + @attrs.define + class Evaporation(Item): + _keyword: ClassVar[str] = "evaporation" + ifno: int = field(index=True, fk="packagedata.ifno") + evaporation: Union[float, str] = field(time_series=True) + + @attrs.define + class Runoff(Item): + _keyword: ClassVar[str] = "runoff" + ifno: int = field(index=True, fk="packagedata.ifno") + runoff: Union[float, str] = field(time_series=True) + + @attrs.define + class ExtInflow(Item): + _keyword: ClassVar[str] = "ext-inflow" + ifno: int = field(index=True, fk="packagedata.ifno") + ext_inflow: Union[float, str] = field(time_series=True) + + @attrs.define + class Auxiliary(Item): + _keyword: ClassVar[str] = "auxiliary" + ifno: int = field(index=True, fk="packagedata.ifno") + auxname: Union[float, str] = field() + auxval: Union[float, str] = field(time_series=True) + + _StressPeriodDataItem = ( + Status | Concentration | Rainfall | Evaporation | Runoff | ExtInflow | Auxiliary + ) flow_package_name: Optional[str] = field( default=None, @@ -76,41 +115,41 @@ class Row(_Row): converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) budget_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) budgetcsv_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) ts_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) obs_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) - packagedata: Optional[list[PackagedataRow]] = field( + packagedata: Optional[list[Packagedata]] = field( default=None, block="packagedata", ) - _stress_period_data: Optional[dict[int, list[Row]]] = field( + _stress_period_data: Optional[dict[int, list[_StressPeriodDataItem]]] = field( alias="stress_period_data", default=None, repr=False, @@ -119,5 +158,11 @@ class Row(_Row): ) -LktRow = Lkt.Row -LktPackagedataRow = Lkt.PackagedataRow +LktPackagedata = Lkt.Packagedata +LktStatus = Lkt.Status +LktConcentration = Lkt.Concentration +LktRainfall = Lkt.Rainfall +LktEvaporation = Lkt.Evaporation +LktRunoff = Lkt.Runoff +LktExtInflow = Lkt.ExtInflow +LktAuxiliary = Lkt.Auxiliary diff --git a/flopy4/mf6/gwt/mst.py b/flopy4/mf6/gwt/mst.py index 68ce0b15..61e1bd29 100644 --- a/flopy4/mf6/gwt/mst.py +++ b/flopy4/mf6/gwt/mst.py @@ -38,7 +38,7 @@ class Mst(Package): converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) export_array_ascii: bool = field( default=False, diff --git a/flopy4/mf6/gwt/mvt.py b/flopy4/mf6/gwt/mvt.py index 163ff68a..5ebaba2e 100644 --- a/flopy4/mf6/gwt/mvt.py +++ b/flopy4/mf6/gwt/mvt.py @@ -33,12 +33,12 @@ class Mvt(Package): converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) budgetcsv_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) diff --git a/flopy4/mf6/gwt/oc.py b/flopy4/mf6/gwt/oc.py index b2010c5a..ab1c6dc0 100644 --- a/flopy4/mf6/gwt/oc.py +++ b/flopy4/mf6/gwt/oc.py @@ -1,10 +1,11 @@ # autogenerated file, do not modify from pathlib import Path -from typing import ClassVar, Optional +from typing import ClassVar, Optional, Union import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package from flopy4.mf6.record import Record from flopy4.mf6.spec import field, path @@ -15,60 +16,66 @@ class Oc(Package): dfn_name: ClassVar[str] = "gwt-oc" @attrs.define - class Concentrationprint(Record): - _keyword: ClassVar[str] = "concentration" - _extra_tokens: ClassVar[tuple[str, ...]] = ("PRINT_FORMAT",) + class Format(Record): + _keyword: ClassVar[str] = "" format_: str = attrs.field() columns: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) width: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) digits: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) + @attrs.define + class Concentrationprint(Record): + _keyword: ClassVar[str] = "concentration" + _extra_tokens: ClassVar[tuple[str, ...]] = ("PRINT_FORMAT",) + formatrecord: "Oc.Format" = attrs.field() + + @attrs.define + class Save(Item): + _keyword: ClassVar[str] = "save" + rtype: Union[float, str] = field() + ocsetting: tuple = field(default=(), array=True) + + @attrs.define + class Print(Item): + _keyword: ClassVar[str] = "print" + rtype: Union[float, str] = field() + ocsetting: tuple = field(default=(), array=True) + + _StressPeriodDataItem = Save | Print + budget_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) budgetcsv_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) concentration_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) concentrationprint: Optional[Concentrationprint] = field( default=None, block="options", ) - save_concentration: Optional[dict[int, list[str]]] = field( - default=None, - block="period", - oc_action="save", - oc_rtype="concentration", - ) - save_budget: Optional[dict[int, list[str]]] = field( - default=None, - block="period", - oc_action="save", - oc_rtype="budget", - ) - print_concentration: Optional[dict[int, list[str]]] = field( - default=None, - block="period", - oc_action="print", - oc_rtype="concentration", - ) - print_budget: Optional[dict[int, list[str]]] = field( + _stress_period_data: Optional[dict[int, list[_StressPeriodDataItem]]] = field( + alias="stress_period_data", default=None, + repr=False, block="period", - oc_action="print", - oc_rtype="budget", + fill_forward=True, ) + + +OcSave = Oc.Save +OcPrint = Oc.Print diff --git a/flopy4/mf6/gwt/src.py b/flopy4/mf6/gwt/src.py index 5856b235..f3f4af52 100644 --- a/flopy4/mf6/gwt/src.py +++ b/flopy4/mf6/gwt/src.py @@ -5,12 +5,10 @@ import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path -_Row = Row - @attrs.define(kw_only=True, slots=False) class Src(Package): @@ -19,7 +17,7 @@ class Src(Package): multi_package: ClassVar[bool] = True @attrs.define - class Row(_Row): + class StressPeriodData(Item): cellid: tuple = field(cellid=True) smassrate: Union[float, str] = field(time_series=True) aux: tuple = () @@ -60,14 +58,14 @@ class Row(_Row): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) obs_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) highest_saturated: bool = field( default=False, @@ -79,7 +77,7 @@ class Row(_Row): block="dimensions", auto_from="stress_period_data", ) - _stress_period_data: Optional[dict[int, list[Row]]] = field( + _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( alias="stress_period_data", default=None, repr=False, @@ -88,4 +86,4 @@ class Row(_Row): ) -SrcRow = Src.Row +SrcStressPeriodData = Src.StressPeriodData diff --git a/flopy4/mf6/gwt/ssm.py b/flopy4/mf6/gwt/ssm.py index a3120b8c..4112567f 100644 --- a/flopy4/mf6/gwt/ssm.py +++ b/flopy4/mf6/gwt/ssm.py @@ -4,8 +4,8 @@ import attrs +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path @@ -14,15 +14,15 @@ class Ssm(Package): dfn_name: ClassVar[str] = "gwt-ssm" @attrs.define - class SourcesRow(Row): - pname: Union[float, str] - srctype: Union[float, str] - auxname: Union[float, str] + class Sources(Item): + pname: Union[float, str] = field() + srctype: Union[float, str] = field() + auxname: Union[float, str] = field() @attrs.define - class FileinputRow(Row): - pname: Union[float, str] - spc6_filename: Path = path(converter=Path, inout="filein", prefix=("SPC6",)) + class Fileinput(Item): + pname: Union[float, str] = field() + spc6_filename: Path = path(converter=Path, direction="in", prefix=("SPC6",)) mixed: Optional[str] = field(default=None, tagged=True, optional=True) print_flows: bool = field( @@ -35,16 +35,16 @@ class FileinputRow(Row): block="options", optional=True, ) - sources: Optional[list[SourcesRow]] = field( + sources: Optional[list[Sources]] = field( default=None, block="sources", always_emit=True, ) - fileinput: Optional[list[FileinputRow]] = field( + fileinput: Optional[list[Fileinput]] = field( default=None, block="fileinput", ) -SsmSourcesRow = Ssm.SourcesRow -SsmFileinputRow = Ssm.FileinputRow +SsmSources = Ssm.Sources +SsmFileinput = Ssm.Fileinput diff --git a/flopy4/mf6/ims.py b/flopy4/mf6/ims.py index 9719c764..d8c38c38 100644 --- a/flopy4/mf6/ims.py +++ b/flopy4/mf6/ims.py @@ -42,21 +42,21 @@ class Rclose(Record): converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) csv_outer_output_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) csv_inner_output_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) no_ptc: Optional[NoPtc] = field( default=None, diff --git a/flopy4/mf6/item.py b/flopy4/mf6/item.py new file mode 100644 index 00000000..936c92fa --- /dev/null +++ b/flopy4/mf6/item.py @@ -0,0 +1,390 @@ +"""Item(Record): one row of a repeating tabular block (packagedata, +connectiondata, period data, ...). + +Adds what Record doesn't need: index/pk/fk renumbering, cellid packing, +aux/boundname trailing columns, and external parse context (ncelldim/naux/ +boundnames -- facts about the surrounding list/package, not one row). A +tagged field here is always a bare presence flag (e.g. LAK tables' MIXED) -- +unlike Record's tagged fields, which may carry a value. + +An Item class MAY declare ``_keyword: ClassVar[str]`` for the +keystring-union-arm case (e.g. LAK/SFR/MAW/UZF period settings): several +Item subclasses share one field (a Union of their types), each identified +by its own leading keyword token (STATUS/STAGE/RATE/...). +""" + +import re +from pathlib import Path +from typing import Any, Union, get_args, get_origin + +import attrs + +from flopy4.mf6.record import Record, _coerce, keyword_of, record_fields + +_AUX_KEY_RE = re.compile(r"^aux(\d+)$") + + +def normalize_aux_keys(item: dict) -> dict: + """Collapse legacy aux0/aux1/... dict keys into one aux tuple key.""" + aux_items = [] + rest = {} + for k, v in item.items(): + m = _AUX_KEY_RE.match(k) + if m: + aux_items.append((int(m.group(1)), v)) + else: + rest[k] = v + if aux_items: + aux_items.sort(key=lambda kv: kv[0]) + rest["aux"] = tuple(v for _, v in aux_items) + return rest + + +def _cellid_field(cls: type) -> attrs.Attribute | None: + return next((f for f in record_fields(cls) if f.metadata.get("cellid")), None) + + +def _has_aux_field(cls: type) -> bool: + return any(f.name == "aux" for f in record_fields(cls)) + + +def _has_boundname_field(cls: type) -> bool: + return any(f.name == "boundname" for f in record_fields(cls)) + + +def construct_item(item_cls: type, values) -> "Item": + """Build an Item from a flat positional tuple, e.g. ``(cellid, q, 35.0)`` + for one aux variable, or ``("HEAD", "FREQUENCY", 2)`` for OC's Save + (rtype, ocsetting). Everything from the aux/array field's position + onward collects into that one field's tuple, except a trailing string + when the class also has boundname (always declared last) -- a string + there unambiguously isn't a numeric aux value. + """ + fields = record_fields(item_cls) + tuple_idx = next( + (i for i, f in enumerate(fields) if f.name == "aux" or f.metadata.get("array")), None + ) + values = list(values) + if tuple_idx is None: + return item_cls(*values) + boundname_val = None + if ( + fields + and fields[-1].name == "boundname" + and len(values) > tuple_idx + and isinstance(values[-1], str) + ): + boundname_val = values[-1] + values = values[:-1] + before = values[:tuple_idx] + tuple_vals = tuple(values[tuple_idx:]) + if boundname_val is not None: + return item_cls(*before, tuple_vals, boundname=boundname_val) + return item_cls(*before, tuple_vals) + + +def _n_fixed_tokens(cls: type) -> int: + """Fixed (non-cellid/aux/boundname, non-optional) token slots -- used to + infer a variable-width cellid's element count from total token length.""" + n = 1 if keyword_of(cls) else 0 + for f in record_fields(cls): + if f.metadata.get("cellid") or f.name in ("aux", "boundname"): + continue + if f.metadata.get("optional"): + continue + n += 1 + len(f.metadata.get("prefix", ())) + if f.metadata.get("direction"): + n += 1 + return n + + +def infer_ncelldim(items: list[list], item_cls: "type[Item]", *, naux: int = 0) -> int: + """Infer an Item class's cellid width from the first non-empty raw item: + total tokens minus fixed columns minus aux minus a trailing boundname.""" + if _cellid_field(item_cls) is None: + return 0 + first = next((r for r in items if r), None) + if not first: + return 0 + n_fixed = _n_fixed_tokens(item_cls) + last = first[-1] + has_bn = isinstance(last, str) and not _token_fits(last, float) + return max(1, len(first) - n_fixed - naux - (1 if has_bn else 0)) + + +def _token_fits(token: Any, kind: type) -> bool: + """True if token coerces to kind -- tells a trailing boundname string + apart from a trailing numeric value.""" + if isinstance(token, (int, float)): + return True + if isinstance(token, str): + try: + float(token) + return True + except ValueError: + return False + return False + + +class Item(Record): + """Mixin for generated table-item types (plain items and keystring-union + arms alike -- see module docstring).""" + + def to_tokens(self) -> tuple: + """index -> 1-based; cellid likewise per element. _keyword (if any) + is emitted before the first non-index field. aux/boundname last. + """ + cls = type(self) + fields = record_fields(cls) + keyword = keyword_of(cls) + row: list[Any] = [] + keyword_emitted = not keyword + for f in fields: + if f.name in ("aux", "boundname"): + continue + val = getattr(self, f.name) + if val is None: + continue + if f.metadata.get("cellid"): + row.extend(int(c) + 1 for c in val) + elif f.metadata.get("index"): + row.append(int(val) + 1) + elif f.metadata.get("array"): + if not keyword_emitted: + row.append(keyword.upper()) + keyword_emitted = True + row.extend(val) + elif f.metadata.get("tagged"): + if not keyword_emitted: + row.append(keyword.upper()) + keyword_emitted = True + if val: + row.append(f.name.upper()) + else: + if not keyword_emitted: + row.append(keyword.upper()) + keyword_emitted = True + if prefix := f.metadata.get("prefix"): + row.extend(prefix) + if direction := f.metadata.get("direction"): + row.append("FILEOUT" if direction == "out" else "FILEIN") + row.append(str(val) if isinstance(val, Path) else val) + if not keyword_emitted: + row.append(keyword.upper()) + aux = getattr(self, "aux", None) + if aux: + row.extend(aux) + boundname = getattr(self, "boundname", None) + if boundname: + row.append(boundname) + return tuple(row) + + @classmethod + def from_tokens( # type: ignore[override] + cls, tokens: list, *, ncelldim: int = 0, naux: int = 0, boundnames: bool = False + ) -> "Item": + """Mirror of to_tokens. Optional untagged columns (e.g. EVT's + pxdp/petm/petm0) have no marker token -- MF6 writes a whole trailing + group or none, gated by an unrelated OPTIONS flag -- so presence is + inferred once from the token budget left after reserving aux/ + boundname, not per-field. Tagged optional fields self-identify by + keyword and skip that budget. + """ + fields = record_fields(cls) + keyword = keyword_of(cls) + keyword_skipped = not keyword + has_boundname = _has_boundname_field(cls) and boundnames + has_aux = _has_aux_field(cls) + kwargs: dict[str, Any] = {} + tok_idx = 0 + n = len(tokens) + + def consume(f: attrs.Attribute) -> None: + nonlocal tok_idx, keyword_skipped + if f.metadata.get("cellid"): + cellid = tuple(int(tokens[tok_idx + j]) - 1 for j in range(ncelldim)) + kwargs[f.name] = cellid + tok_idx += ncelldim + return + if f.metadata.get("index"): + kwargs[f.name] = int(float(str(tokens[tok_idx]))) - 1 + tok_idx += 1 + return + if not keyword_skipped: + tok_idx += 1 + keyword_skipped = True + if prefix := f.metadata.get("prefix"): + tok_idx += len(prefix) + if f.metadata.get("direction"): + tok_idx += 1 + if tok_idx >= n: + return + kwargs[f.name] = _coerce(tokens[tok_idx], f) + tok_idx += 1 + + def width(f: attrs.Attribute) -> int: + w = 1 + len(f.metadata.get("prefix", ())) + if f.metadata.get("direction"): + w += 1 + return w + + main_fields = [f for f in fields if f.name not in ("aux", "boundname")] + array_fields = [f for f in main_fields if f.metadata.get("array")] + main_fields = [f for f in main_fields if not f.metadata.get("array")] + required_fields = [f for f in main_fields if not f.metadata.get("optional")] + optional_fields = [f for f in main_fields if f.metadata.get("optional")] + + for f in required_fields: + consume(f) + + has_bn_token = False + if has_boundname and n > tok_idx: + last = tokens[-1] + has_bn_token = isinstance(last, str) and not _token_fits(last, float) + remaining = n - tok_idx - (1 if has_bn_token else 0) - (naux if has_aux else 0) + + budget_fields = [f for f in optional_fields if not f.metadata.get("tagged")] + n_opt_present = 0 + used = 0 + for f in budget_fields: + w = width(f) + if used + w > remaining: + break + used += w + n_opt_present += 1 + + budget_idx = 0 + for f in optional_fields: + if f.metadata.get("tagged"): + if not keyword_skipped: + tok_idx += 1 + keyword_skipped = True + kw = f.name.upper() + if tok_idx < n and str(tokens[tok_idx]).upper() == kw: + kwargs[f.name] = str(tokens[tok_idx]) + tok_idx += 1 + continue + present = budget_idx < n_opt_present + budget_idx += 1 + if present: + consume(f) + + if array_fields: + # Consumes everything left up to aux/boundname's own reserved + # slots -- a keyword-plus-trailing-values setting (OC/PRP's + # ocsetting/releasesetting: bare ALL/FIRST/LAST, "FREQUENCY n", + # or "STEPS n1 n2 ..."), coerced numeric-or-string per token + # like aux (see below) since the arity and type aren't fixed. + if not keyword_skipped: + tok_idx += 1 + keyword_skipped = True + f = array_fields[0] + end = n - (1 if has_bn_token else 0) - (naux if has_aux else 0) + vals = [] + while tok_idx < end: + tok = tokens[tok_idx] + try: + vals.append(float(tok)) + except (ValueError, TypeError): + vals.append(tok) + tok_idx += 1 + kwargs[f.name] = tuple(vals) + elif not keyword_skipped: + tok_idx += 1 + + if has_aux: + aux_vals = [] + end = n - (1 if has_bn_token else 0) + while tok_idx < end: + tok = tokens[tok_idx] + try: + aux_vals.append(float(tok)) + except (ValueError, TypeError): + aux_vals.append(tok) + tok_idx += 1 + kwargs["aux"] = tuple(aux_vals) + + if has_bn_token: + kwargs["boundname"] = str(tokens[-1]) + + return cls(**kwargs) + + +def _unwrap_item(item) -> "type[Item] | tuple[type[Item], ...] | None": + """A single Item subclass, or the tuple of arm subclasses for a Union + (keystring-arm) item type.""" + if isinstance(item, type) and issubclass(item, Item): + return item + origin = get_origin(item) + if origin is Union or origin is type(int | str): + arms = tuple(a for a in get_args(item) if isinstance(a, type) and issubclass(a, Item)) + return arms or None + return None + + +def item_list_type(field_type) -> "type[Item] | tuple[type[Item], ...] | None": + """For Optional[list[C]] or Optional[dict[int, list[C]]], return C (or + the tuple of arm classes for a Union item type).""" + args = get_args(field_type) + inner = next((a for a in args if a is not type(None)), None) + if inner is None: + return None + origin = get_origin(inner) + if origin is list: + return _unwrap_item(get_args(inner)[0]) + if origin is dict: + _, val = get_args(inner) + if get_origin(val) is list: + return _unwrap_item(get_args(val)[0]) + return None + + +def dispatch_union_item(item: list, arm_classes: "tuple[type[Item], ...]") -> "type[Item] | None": + """Find which arm class a raw token item belongs to, by its leading + _keyword token.""" + kw_map = {keyword_of(c).upper(): c for c in arm_classes if keyword_of(c)} + for t in item: + arm_cls = kw_map.get(str(t).upper()) + if arm_cls is not None: + return arm_cls + return None + + +def construct_union_item(values, arm_classes: "tuple[type[Item], ...]") -> "Item | None": + """Build an Item from a flat user-supplied positional tuple for a + keystring-union field, e.g. ``(0, "STATUS", "ACTIVE")``. + + Dispatches to the right arm by keyword token, same as dispatch_union_item, + then drops that token and builds the rest positionally via construct_item + -- unlike parse_union_items/from_tokens, the remaining values are already + Python-side (a 0-based int, a real float, ...), not raw 1-based/string + file tokens, so they must NOT go through from_tokens's index/type + conversion a second time. + """ + values = list(values) + arm_cls = dispatch_union_item(values, arm_classes) + if arm_cls is None: + return None + kw = keyword_of(arm_cls).upper() + kw_idx = next((i for i, v in enumerate(values) if str(v).upper() == kw), None) + if kw_idx is not None: + values = values[:kw_idx] + values[kw_idx + 1 :] + return construct_item(arm_cls, values) + + +def parse_union_items( + items: list, arm_classes: "tuple[type[Item], ...]", *, naux: int = 0, boundnames: bool = False +) -> list | None: + """Parse raw token items into Item instances, dispatching each by + keyword (see dispatch_union_item); unmatched items are skipped.""" + if not items: + return None + result = [] + for item in items: + if not item: + continue + arm_cls = dispatch_union_item(item, arm_classes) + if arm_cls is None: + continue + result.append(arm_cls.from_tokens(item, naux=naux, boundnames=boundnames)) + return result or None diff --git a/flopy4/mf6/package.py b/flopy4/mf6/package.py index 095ac4e7..f62bb2f6 100644 --- a/flopy4/mf6/package.py +++ b/flopy4/mf6/package.py @@ -8,11 +8,17 @@ from xattree import xattree from flopy4.mf6.component import Component -from flopy4.mf6.row import Row, construct_row, dispatch_union_row, normalize_aux_keys, row_list_type +from flopy4.mf6.item import ( + Item, + construct_item, + construct_union_item, + item_list_type, + normalize_aux_keys, +) from flopy4.mf6.spec import to_field_type # DFN type -> numpy dtype, for broadcasting a scalar griddata default to a -# full array (unrelated to the old Schema/Column row-parsing machinery). +# full array. _DTYPE_MAP: dict = { "integer": np.int64, "double": np.float64, @@ -29,10 +35,8 @@ def __attrs_post_init__(self) -> None: Handles three concerns in order: 1. Fix xattree name registration (concrete class name, not 'package'). - 2. Coerce raw list/block/period data into Row-list fields (the - generated field's own type annotation -- Optional[list[RowClass]] - or Optional[dict[int, list[RowClass]]] -- is the schema; no - separate Schema/Column description), auto-set maxbound/ns. + 2. Coerce raw list/block/period data into Item-list fields, and + auto-set maxbound/ns. 3. Broadcast scalar griddata values to their DFN shape when dims is supplied (e.g. IC(strt=1.0, dims={"nodes": 900})). """ @@ -52,16 +56,16 @@ def __attrs_post_init__(self) -> None: if self.__dict__.get("name") == "package": self.__dict__["name"] = type(self).__name__.lower() - # 2. Row-list coercion. - self._init_row_lists(fields) + # 2. Item-list coercion. + self._init_item_lists(fields) # 3. Griddata broadcasting. dims: dict = self.__dict__.get("dims") or {} if dims: self._broadcast_griddata(fields, dims) - def _init_row_lists(self, fields) -> None: - """Coerce raw list/dict block+period data into Row-list fields; + def _init_item_lists(self, fields) -> None: + """Coerce raw list/dict block+period data into Item-list fields; auto-set maxbound / ns from the resulting list lengths. Reads/writes the field's real attribute name (f.name) always -- @@ -73,77 +77,80 @@ def _init_row_lists(self, fields) -> None: block = f.metadata.get("block") if not block: continue - row_cls = row_list_type(f.type) - if row_cls is None: + item_cls = item_list_type(f.type) + if item_cls is None: continue raw = self.__dict__.get(f.name) if raw is None: continue if block == "period" or f.metadata.get("fill_forward"): - coerced = {kper: self._coerce_row_list(rows, row_cls) for kper, rows in raw.items()} + coerced = { + kper: self._coerce_item_list(rows, item_cls) for kper, rows in raw.items() + } object.__setattr__(self, f.name, coerced) if coerced and getattr(self, "maxbound", None) == 0: object.__setattr__(self, "maxbound", max(len(v) for v in coerced.values())) else: - coerced_list = self._coerce_row_list(raw, row_cls) + coerced_list = self._coerce_item_list(raw, item_cls) object.__setattr__(self, f.name, coerced_list) if getattr(self, f"n{block}s", 0) == 0: object.__setattr__(self, f"n{block}s", len(coerced_list)) @staticmethod - def _coerce_row_list(data, row_cls: "type[Row] | tuple[type[Row], ...]") -> list: - """Convert user-supplied list/dict data to a list of Row instances. + def _coerce_item_list(data, item_cls: "type[Item] | tuple[type[Item], ...]") -> list: + """Convert user-supplied list/dict data to a list of Item instances. - For a plain (non-union) row_cls, accepts: - - list of row_cls instances → returned as-is - - list of tuples/lists → positional, matching row_cls's + For a plain (non-union) item_cls, accepts: + - list of item_cls instances → returned as-is + - list of tuples/lists → positional, matching item_cls's own field declaration order - list of dicts → named columns - dict of lists → column-oriented {col_name: [values]} - For a keystring-union row_cls (a tuple of arm classes, e.g. LAK's - (LakStatusItem, LakStageItem, ...)): existing arm instances pass - through; tuples/lists/dicts are dispatched to the right arm by - their keyword token/"keyword" key, the same way file rows are (see - flopy4.mf6.row.dispatch_union_row) -- ambiguous columnar dict-of- - lists input isn't supported (no single arm to build columns from). + For a keystring-union item_cls (a tuple of arm classes, e.g. LAK's + (LakStatus, LakStage, ...)): existing arm instances pass through; + tuples/lists are dispatched to the right arm by their keyword token + and built positionally (see flopy4.mf6.item.construct_union_item -- + NOT from_tokens, since these values are already Python-side, not + raw 1-based/string file tokens); dicts are dispatched by a + "keyword" key. Ambiguous columnar dict-of-lists input isn't + supported (no single arm to build columns from). """ - if isinstance(row_cls, tuple): - rows = [] + if isinstance(item_cls, tuple): + items = [] for row in data: - if isinstance(row, row_cls): - rows.append(row) + if isinstance(row, item_cls): + items.append(row) elif isinstance(row, dict): kw = str(row.get("keyword", "")).upper() arm = next( - (c for c in row_cls if c.__dict__.get("_keyword", "").upper() == kw), None + (c for c in item_cls if c.__dict__.get("_keyword", "").upper() == kw), None ) if arm is not None: - rows.append(arm(**{k: v for k, v in row.items() if k != "keyword"})) + items.append(arm(**{k: v for k, v in row.items() if k != "keyword"})) else: - tokens = list(row) - arm = dispatch_union_row(tokens, row_cls) - if arm is not None: - rows.append(arm.from_row(tokens)) - return rows + item = construct_union_item(row, item_cls) + if item is not None: + items.append(item) + return items if isinstance(data, dict): n = len(next(iter(data.values()))) if data else 0 return [ - row_cls(**normalize_aux_keys({name: vals[i] for name, vals in data.items()})) + item_cls(**normalize_aux_keys({name: vals[i] for name, vals in data.items()})) for i in range(n) ] - rows = [] + items = [] for row in data: - if isinstance(row, row_cls): - rows.append(row) + if isinstance(row, item_cls): + items.append(row) elif isinstance(row, dict): - rows.append(row_cls(**normalize_aux_keys(row))) + items.append(item_cls(**normalize_aux_keys(row))) elif isinstance(row, (list, tuple)): - rows.append(construct_row(row_cls, row)) + items.append(construct_item(item_cls, row)) else: - rows.append(construct_row(row_cls, list(row))) - return rows + items.append(construct_item(item_cls, list(row))) + return items def _broadcast_griddata(self, fields, dims: dict) -> None: """Expand scalar griddata defaults to full arrays when dims is supplied.""" @@ -279,15 +286,15 @@ def from_dataframe(self, df: pd.DataFrame) -> None: """Set stress_period_data from a tidy DataFrame. The DataFrame must have a ``kper`` column and data columns matching - the period Row class's own fields (as produced by ``to_dataframe()``). + the period Item class's own fields (as produced by ``to_dataframe()``). """ if df.empty: self.__dict__["_stress_period_data"] = {} return if "kper" not in df.columns: raise ValueError("DataFrame must have a 'kper' column") - row_cls = self._period_row_cls() - if isinstance(row_cls, tuple): + item_cls = self._period_item_cls() + if isinstance(item_cls, tuple): raise ValueError( f"{type(self).__name__}.from_dataframe() doesn't support a keystring-union " "period field (multiple possible row shapes) -- construct arm instances directly." @@ -295,20 +302,20 @@ def from_dataframe(self, df: pd.DataFrame) -> None: spd: dict[int, list] = {} for kper, group in df.groupby("kper"): group = group.drop(columns=["kper"]) - spd[int(kper)] = [row_cls(**row) for row in group.to_dict("records")] + spd[int(kper)] = [item_cls(**row) for row in group.to_dict("records")] self.__dict__["_stress_period_data"] = spd - def _period_row_cls(self) -> "type[Row] | tuple[type[Row], ...]": + def _period_item_cls(self) -> "type[Item] | tuple[type[Item], ...]": for f in attrs.fields(type(self)): # type: ignore[arg-type] if f.metadata.get("block") == "period" or f.metadata.get("fill_forward"): - row_cls = row_list_type(f.type) - if row_cls is not None: - return row_cls - raise ValueError(f"{type(self).__name__} has no period Row-list field") + item_cls = item_list_type(f.type) + if item_cls is not None: + return item_cls + raise ValueError(f"{type(self).__name__} has no period Item-list field") @property def stress_period_data(self): # type: ignore[override] - """Stress period data as ``dict[int, list[Row]]`` keyed by 0-based kper.""" + """Stress period data as ``dict[int, list[Item]]`` keyed by 0-based kper.""" return self.__dict__.get("_stress_period_data") @stress_period_data.setter # type: ignore[attr-defined, no-redef] diff --git a/flopy4/mf6/prt/disv.py b/flopy4/mf6/prt/disv.py index 050ab1d2..f9fe8152 100644 --- a/flopy4/mf6/prt/disv.py +++ b/flopy4/mf6/prt/disv.py @@ -5,7 +5,7 @@ from numpy.typing import NDArray from flopy4.mf6.gwf.disbase import DisBase -from flopy4.mf6.row import Row +from flopy4.mf6.item import Item from flopy4.mf6.spec import field from flopy4.mf6.utils.grid import VertexGrid @@ -23,7 +23,7 @@ class Cell2dRecord: icvert: tuple[int, ...] = attrs.field() @attrs.define - class VerticesRow(Row): + class Vertices(Item): iv: int xv: float yv: float @@ -62,7 +62,7 @@ class VerticesRow(Row): iv: Optional[NDArray[np.int64]] = attrs.field(default=None) xv: Optional[NDArray[np.float64]] = attrs.field(default=None) yv: Optional[NDArray[np.float64]] = attrs.field(default=None) - vertices: Optional[list[VerticesRow]] = field(default=None, block="vertices") + vertices: Optional[list[Vertices]] = field(default=None, block="vertices") cell2ddata: Optional[list] = attrs.field(default=None) cell2d: Optional[list] = field(default=None, init=False, block="cell2d") @@ -75,7 +75,7 @@ def __attrs_post_init__(self): object.__setattr__(self, "yv", np.asarray(self.yv, dtype=np.float64)) if self.iv is not None and self.xv is not None and (self.yv is not None): rows = [ - self.VerticesRow(iv=int(iv) + 1, xv=float(xv), yv=float(yv)) + self.Vertices(iv=int(iv) + 1, xv=float(xv), yv=float(yv)) for iv, xv, yv in zip(self.iv, self.xv, self.yv) ] object.__setattr__(self, "vertices", rows) diff --git a/flopy4/mf6/prt/fmi.py b/flopy4/mf6/prt/fmi.py index f371c33d..7d954422 100644 --- a/flopy4/mf6/prt/fmi.py +++ b/flopy4/mf6/prt/fmi.py @@ -23,26 +23,26 @@ class Fmi(Package): converter=_optional_path, block="packagedata", optional=True, - inout="filein", + direction="in", ) gwfbudget: Optional[Path] = path( default=None, converter=_optional_path, block="packagedata", optional=True, - inout="filein", + direction="in", ) gwfspdis: Optional[Path] = path( default=None, converter=_optional_path, block="packagedata", optional=True, - inout="filein", + direction="in", ) gwfgrid: Optional[Path] = path( default=None, converter=_optional_path, block="packagedata", optional=True, - inout="filein", + direction="in", ) diff --git a/flopy4/mf6/prt/oc.py b/flopy4/mf6/prt/oc.py index 3330b483..4c72b7b0 100644 --- a/flopy4/mf6/prt/oc.py +++ b/flopy4/mf6/prt/oc.py @@ -1,13 +1,13 @@ # autogenerated file, do not modify from pathlib import Path -from typing import ClassVar, Optional +from typing import ClassVar, Optional, Union import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package from flopy4.mf6.record import Record -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path @@ -21,36 +21,50 @@ class TrackTimesfile(Record): timesfile: str = attrs.field() @attrs.define - class TracktimesRow(Row): - time: float + class Tracktimes(Item): + time: float = field() + + @attrs.define + class Save(Item): + _keyword: ClassVar[str] = "save" + rtype: Union[float, str] = field() + ocsetting: tuple = field(default=(), array=True) + + @attrs.define + class Print(Item): + _keyword: ClassVar[str] = "print" + rtype: Union[float, str] = field() + ocsetting: tuple = field(default=(), array=True) + + _StressPeriodDataItem = Save | Print budget_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) budgetcsv_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) track_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) trackcsv_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) track_release: bool = field( default=False, @@ -97,28 +111,30 @@ class TracktimesRow(Row): default=None, block="options", ) + scratch_buffer: bool = field( + default=False, + block="options", + optional=True, + ) ntracktimes: Optional[int] = field( default=None, block="dimensions", optional=True, ) - save_budget: Optional[dict[int, list[str]]] = field( + tracktimes: Optional[list[Tracktimes]] = field( default=None, - block="period", - oc_action="save", - oc_rtype="budget", + block="tracktimes", + auto_from="tracktimes", ) - print_budget: Optional[dict[int, list[str]]] = field( + _stress_period_data: Optional[dict[int, list[_StressPeriodDataItem]]] = field( + alias="stress_period_data", default=None, + repr=False, block="period", - oc_action="print", - oc_rtype="budget", - ) - tracktimes: Optional[list[TracktimesRow]] = field( - default=None, - block="tracktimes", - auto_from="tracktimes", + fill_forward=True, ) -OcTracktimesRow = Oc.TracktimesRow +OcTracktimes = Oc.Tracktimes +OcSave = Oc.Save +OcPrint = Oc.Print diff --git a/flopy4/mf6/prt/prp.py b/flopy4/mf6/prt/prp.py index a66c4d15..ffd0e63d 100644 --- a/flopy4/mf6/prt/prp.py +++ b/flopy4/mf6/prt/prp.py @@ -5,13 +5,11 @@ import attrs from flopy4.mf6._types import _optional_path +from flopy4.mf6.item import Item from flopy4.mf6.package import Package from flopy4.mf6.record import Record -from flopy4.mf6.row import Row from flopy4.mf6.spec import field, path -_Row = Row - @attrs.define(kw_only=True, slots=False) class Prp(Package): @@ -25,8 +23,8 @@ class ReleaseTimesfile(Record): timesfile: str = attrs.field() @attrs.define - class PackagedataRow(Row): - irptno: int = field(pk=True) + class Packagedata(Item): + irptno: int = field(index=True, pk=True) cellid: tuple = field(cellid=True) xrpt: float = field() yrpt: float = field() @@ -35,13 +33,37 @@ class PackagedataRow(Row): boundname: Optional[str] = field(default=None, optional=True) @attrs.define - class ReleasetimesRow(Row): - time: float + class Releasetimes(Item): + time: float = field() + + @attrs.define + class All(Item): + _keyword: ClassVar[str] = "all" + + @attrs.define + class First(Item): + _keyword: ClassVar[str] = "first" + + @attrs.define + class Last(Item): + _keyword: ClassVar[str] = "last" + + @attrs.define + class Frequency(Item): + _keyword: ClassVar[str] = "frequency" + frequency: int = field() @attrs.define - class Row(_Row): - keyword: str - value: Optional[object] = field(default=None, optional=True) + class Steps(Item): + _keyword: ClassVar[str] = "steps" + steps: tuple = field(default=(), array=True) + + @attrs.define + class Fraction(Item): + _keyword: ClassVar[str] = "fraction" + fraction: tuple = field(default=(), array=True) + + _StressPeriodDataItem = All | First | Last | Frequency | Steps | Fraction boundnames: bool = field( default=False, @@ -73,14 +95,14 @@ class Row(_Row): converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) trackcsv_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) stoptime: Optional[float] = field( default=None, @@ -140,17 +162,17 @@ class Row(_Row): default=None, block="dimensions", ) - packagedata: Optional[list[PackagedataRow]] = field( + packagedata: Optional[list[Packagedata]] = field( default=None, block="packagedata", auto_from="packagedata", ) - releasetimes: Optional[list[ReleasetimesRow]] = field( + releasetimes: Optional[list[Releasetimes]] = field( default=None, block="releasetimes", auto_from="releasetimes", ) - _stress_period_data: Optional[dict[int, list[Row]]] = field( + _stress_period_data: Optional[dict[int, list[_StressPeriodDataItem]]] = field( alias="stress_period_data", default=None, repr=False, @@ -159,6 +181,11 @@ class Row(_Row): ) -PrpRow = Prp.Row -PrpPackagedataRow = Prp.PackagedataRow -PrpReleasetimesRow = Prp.ReleasetimesRow +PrpPackagedata = Prp.Packagedata +PrpReleasetimes = Prp.Releasetimes +PrpAll = Prp.All +PrpFirst = Prp.First +PrpLast = Prp.Last +PrpFrequency = Prp.Frequency +PrpSteps = Prp.Steps +PrpFraction = Prp.Fraction diff --git a/flopy4/mf6/pts.py b/flopy4/mf6/pts.py index 7148f6de..815d85d7 100644 --- a/flopy4/mf6/pts.py +++ b/flopy4/mf6/pts.py @@ -34,21 +34,21 @@ class NoPtc(Record): converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) csv_outer_output_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) csv_inner_output_file: Optional[Path] = path( default=None, converter=_optional_path, block="options", optional=True, - inout="fileout", + direction="out", ) no_ptc: Optional[NoPtc] = field( default=None, diff --git a/flopy4/mf6/record.py b/flopy4/mf6/record.py index 5413a51a..e0ce7ba1 100644 --- a/flopy4/mf6/record.py +++ b/flopy4/mf6/record.py @@ -1,56 +1,141 @@ -"""Base class for generated MF6 inner-class record types.""" +"""Base class for generated MF6 inner-class record types. +Item (item.py) subclasses Record and adds what a table row needs beyond +this: index/pk/fk renumbering, cellid packing, aux/boundname, and external +parse context. Record itself has none of that -- just a keyword-tagged or +positional compound value. + +A Record can also compose another Record (a DFN record nested inside +another) rather than flattening the nested one's fields into itself -- +see _nested_class, inferred from the field's own type annotation rather +than a declared flag, and make.py's _build_record_class_specs. +""" + +import sys import types -from typing import cast, get_args, get_origin +from functools import lru_cache +from pathlib import Path +from typing import Any, Union, cast, get_args, get_origin import attrs -def _coerce(token: str, f: attrs.Attribute): - """Cast a string token to the scalar type declared on an attrs field.""" +def keyword_of(cls: type) -> str: + return vars(cls).get("_keyword", "") + + +@lru_cache(maxsize=None) +def _nested_class(cls: type, type_str: str) -> "type[Record] | None": + """If a field's raw type annotation (e.g. ``"Format"`` or + ``"Optional[Oc.Format]"``) names a Record subclass, return it; else + None. No declared "is this nested" flag needed -- resolvability against + a real Record subclass is itself the signal. + + Composed record classes (see item.py's module docstring and + make.py's _build_record_class_specs) are generated as flat siblings + inside the same package class regardless of DFN nesting depth, so + cls's immediate enclosing class -- one level up in __qualname__ -- + always owns the name being resolved. Resolving here (rather than at + class-body-execution time, via a bare or even same-enclosing-class + string annotation) is required because Python class bodies can't see + sibling names from an enclosing class scope; the qualified string form + (``"Oc.Format"``) exists only so mypy's own scope analysis can resolve + it too. Cached since to_tokens/from_tokens call this per field, often + repeatedly while parsing many rows. + """ + name = type_str + if name.startswith("Optional[") and name.endswith("]"): + name = name[len("Optional[") : -1] + name = name.rsplit(".", 1)[-1] + obj = sys.modules[cls.__module__] + for part in cls.__qualname__.split(".")[:-1]: + obj = getattr(obj, part) + resolved = getattr(obj, name, None) + return resolved if isinstance(resolved, type) and issubclass(resolved, Record) else None + + +def record_fields(cls: type) -> list[attrs.Attribute]: + """Non-private fields of a Record (or Item) class, in declaration order.""" + all_fields = attrs.fields(cast(type[attrs.AttrsInstance], cls)) + return [f for f in all_fields if not f.name.startswith("_")] + + +def _is_bool_field(f: attrs.Attribute) -> bool: t = f.type origin = get_origin(t) - if origin is types.UnionType or origin is getattr(__import__("typing"), "Union", None): - t = next((a for a in get_args(t) if a is not type(None)), str) - if t is int: - return int(token) - if t is float: + if origin is types.UnionType or origin is Union: + t = next((a for a in get_args(t) if a is not type(None)), t) + return t in (bool, "bool") + + +def _coerce(token: Any, f: attrs.Attribute) -> Any: + """Cast a raw token to a field's declared type (time_series falls back + to the raw string if it isn't a float). Only Optional[X] (a single + non-None union arm) is unwrapped -- a genuine multi-type union like + Union[float, str] is deliberately ambiguous and left as the raw token.""" + if f.metadata.get("time_series"): + try: + return float(token) + except (ValueError, TypeError): + return str(token) + t = f.type + origin = get_origin(t) + if origin is types.UnionType or origin is Union: + args = [a for a in get_args(t) if a is not type(None)] + if len(args) != 1: + return token + t = args[0] + if t in (int, "int"): + return int(float(str(token))) + if t in (float, "float"): return float(token) + if t in (Path, "Path"): + return Path(token) return token +def _tagged_tokens(f: attrs.Attribute, v: Any) -> list: + """Bare ``NAME`` for a true bool flag, ``NAME value`` otherwise.""" + if isinstance(v, bool): + return [f.name.upper()] if v else [] + return [f.name.upper(), v] + + +def _consume_tagged(tokens: list, i: int, f: attrs.Attribute) -> "tuple[Any, int] | None": + """Match field f's tagged keyword at tokens[i]; a bool field needs no + value token, anything else does. None if unmatched or value missing.""" + if str(tokens[i]).upper() != f.name.upper(): + return None + if _is_bool_field(f): + return True, 1 + if i + 1 >= len(tokens): + return None + return _coerce(tokens[i + 1], f), 2 + + class Record: """Mixin for generated inner-class record types. - Provides :meth:`from_tokens` and :meth:`to_tokens` for symmetric - parsing/serialization of inner-class records. + Provides symmetric :meth:`to_tokens`/:meth:`from_tokens`. """ def to_tokens(self) -> tuple: - """Serialize this record to an MF6 token tuple. - - Emits ``_keyword`` (uppercased), ``_extra_tokens``, then field values - in declaration order (tagged fields emit ``NAME value``, untagged bools - emit ``NAME`` when True, untagged scalars emit the raw value). - """ inner_cls = type(self) - keyword: str = vars(inner_cls).get("_keyword", "") + keyword = keyword_of(inner_cls) tokens: list = [keyword.upper()] if keyword else [] for tok in vars(inner_cls).get("_extra_tokens", ()): tokens.append(tok) - all_fields = attrs.fields(inner_cls) # type: ignore[arg-type] + all_fields = record_fields(inner_cls) tagged = [a for a in all_fields if a.metadata.get("tagged")] untagged = [a for a in all_fields if not a.metadata.get("tagged")] for a in tagged + untagged: v = getattr(self, a.name) if v is None: continue - if a.metadata.get("tagged"): - if isinstance(v, bool): - if v: - tokens.append(a.name.upper()) - else: - tokens.extend([a.name.upper(), v]) + if isinstance(v, Record): + tokens.extend(v.to_tokens()) + elif a.metadata.get("tagged"): + tokens.extend(_tagged_tokens(a, v)) elif isinstance(v, bool): if v: tokens.append(a.name.upper()) @@ -60,68 +145,63 @@ def to_tokens(self) -> tuple: @classmethod def from_tokens(cls, tokens: str | list[str]) -> "Record": - """Construct from a raw token string or list. - - The trigger keyword (``_keyword``) and any fixed syntax tokens - (``_extra_tokens``) are stripped if the caller included them. - - Two-pass parsing: - 1. Extract ``KEYWORD value`` pairs for tagged fields. - 2. Fill remaining tokens positionally into required tagged fields - that were not keyword-matched, then into untagged fields. - Optional tagged fields not supplied by keyword are skipped. - - Examples - -------- - All three forms are equivalent for ``Oc.Headprint``:: - - Oc.Headprint.from_tokens("HEAD PRINT_FORMAT COLUMNS 10 WIDTH 12 DIGITS 6 exponential") - Oc.Headprint.from_tokens("COLUMNS 10 WIDTH 12 DIGITS 6 exponential") - Oc.Headprint.from_tokens("exponential") - - Both forms are equivalent for ``Ims.Rclose``:: - - Ims.Rclose.from_tokens("INNER_RCLOSE 0.001") - Ims.Rclose.from_tokens("0.001") + """Parse a token string/list back into an instance. + + Tagged fields are matched by keyword wherever it appears; whatever's + left fills required tagged fields (not keyword-matched) then plain + fields, in declaration order. E.g. for ``Oc.Headprint``, these are + all equivalent: ``"HEAD PRINT_FORMAT COLUMNS 10 WIDTH 12 DIGITS 6 + exponential"``, ``"COLUMNS 10 WIDTH 12 DIGITS 6 exponential"``, + ``"exponential"``. """ if isinstance(tokens, str): tokens = tokens.split() - # Strip leading _keyword and _extra_tokens if caller included them. skip: list[str] = [] - if kw := vars(cls).get("_keyword", ""): + if kw := keyword_of(cls): skip.append(kw.upper()) skip.extend(t.upper() for t in vars(cls).get("_extra_tokens", ())) if [t.upper() for t in tokens[: len(skip)]] == skip: tokens = tokens[len(skip) :] - all_fields = [ - f - for f in attrs.fields(cast(type[attrs.AttrsInstance], cls)) - if not f.name.startswith("_") - ] + all_fields = record_fields(cast(type, cls)) + + def _nested(f: attrs.Attribute) -> "type[Record] | None": + return _nested_class(cast(type, cls), f.type) if isinstance(f.type, str) else None + + nested_fields = [f for f in all_fields if _nested(f) is not None] + if nested_fields: + # A record composed of nested record(s) has, in the current + # corpus, no other fields of its own once _keyword/_extra_tokens + # are stripped -- delegate the rest of the tokens wholesale. + assert len(nested_fields) == 1 and len(nested_fields) == len(all_fields), ( + f"{cls.__name__}: exactly one nested record field, with no plain " + "fields of its own, is the only shape supported so far" + ) + nf = nested_fields[0] + nested_cls = _nested(nf) + assert nested_cls is not None + return cls(**{nf.name: nested_cls.from_tokens(tokens)}) + tagged = {f.name.upper(): f for f in all_fields if f.metadata.get("tagged")} untagged = [f for f in all_fields if not f.metadata.get("tagged")] kwargs: dict = {} consumed: set[int] = set() - # Pass 1: extract KEYWORD value pairs for tagged fields. i = 0 while i < len(tokens): - tok = tokens[i].upper() - if tok in tagged and i + 1 < len(tokens): - f = tagged[tok] - kwargs[f.name] = _coerce(tokens[i + 1], f) - consumed.add(i) - consumed.add(i + 1) - i += 2 - else: + f = tagged.get(tokens[i].upper()) + result = None if f is None else _consume_tagged(tokens, i, f) + if f is None or result is None: i += 1 + continue + val, width = result + kwargs[f.name] = val + for j in range(width): + consumed.add(i + j) + i += width - # Pass 2: fill remaining tokens positionally. - # Required tagged fields that were not keyword-matched come first - # (in declaration order), then all untagged fields. required_tagged = [ f for f in all_fields diff --git a/flopy4/mf6/row.py b/flopy4/mf6/row.py deleted file mode 100644 index d1625acd..00000000 --- a/flopy4/mf6/row.py +++ /dev/null @@ -1,410 +0,0 @@ -"""Base class + helpers for generated MF6 list/table row types. - -Companion to record.py's Record: where Record is one compound value with a -leading trigger keyword (e.g. an inner options-block record), Row is one row -of a repeating tabular block (packagedata, connectiondata, period data, ...). -There is no separate Schema/Column description -- a Row class's own attrs -fields, with metadata set via field()'s pk=/fk=/cellid=/time_series=/ -prefix=/tagged= kwargs, ARE the schema. structure.py/unstructure.py -introspect the Row class directly via attrs.fields(), the same way -record.py's from_tokens/to_tokens already do for Record. - -A Row class MAY also declare ``_keyword: ClassVar[str]`` -- this is the -keystring-union-arm case (e.g. LAK/SFR/MAW/UZF period settings, OC's -ocsetting): several Row subclasses share one field (a Python Union of -their types), each identified by its own leading MF6 keyword token -(STATUS/STAGE/RATE/...). Plain (non-union) Row classes simply omit -_keyword, in which case to_row/from_row behave exactly as for any other -tabular row. -""" - -import re -import types -from pathlib import Path -from typing import Any, Union, get_args, get_origin - -import attrs - -_AUX_KEY_RE = re.compile(r"^aux(\d+)$") - - -def normalize_aux_keys(row: dict) -> dict: - """Collapse legacy per-column aux0/aux1/... dict keys into a single aux - tuple key, matching the Row class's single `aux: tuple = ()` field - (the old recarray dtype addressed aux values as separate named columns; - Row uses one tuple field instead -- see module docstring).""" - aux_items = [] - rest = {} - for k, v in row.items(): - m = _AUX_KEY_RE.match(k) - if m: - aux_items.append((int(m.group(1)), v)) - else: - rest[k] = v - if aux_items: - aux_items.sort(key=lambda kv: kv[0]) - rest["aux"] = tuple(v for _, v in aux_items) - return rest - - -def _row_fields(cls: type) -> list[attrs.Attribute]: - """Non-private fields of a Row class, in declaration order.""" - return [f for f in attrs.fields(cls) if not f.name.startswith("_")] - - -def _cellid_field(cls: type) -> attrs.Attribute | None: - return next((f for f in _row_fields(cls) if f.metadata.get("cellid")), None) - - -def _has_aux_field(cls: type) -> bool: - return any(f.name == "aux" for f in _row_fields(cls)) - - -def _has_boundname_field(cls: type) -> bool: - return any(f.name == "boundname" for f in _row_fields(cls)) - - -def construct_row(row_cls: type, values) -> "Row": - """Build a Row instance from a flat positional tuple/list. - - A Row with an ``aux`` field expects (in field-declaration order) - required columns, then a *single* aux tuple, then optionally a trailing - ``boundname`` -- but a flat user-supplied tuple carries aux values - inline, one per position (e.g. ``(cellid, q, 35.0)`` for one aux - variable, matching how the legacy per-column ``aux0``/``aux1`` dtype - fields were addressed positionally), with no way to tell "these are 2 - aux values" from "this field has 2 values" by position/count alone. - Everything from the aux field's position onward is treated as aux, - except a trailing string when the row also has a ``boundname`` field -- - boundname is always declared last (see row_class()), and a string value - there unambiguously isn't a numeric aux value. - """ - fields = _row_fields(row_cls) - aux_idx = next((i for i, f in enumerate(fields) if f.name == "aux"), None) - values = list(values) - if aux_idx is None: - return row_cls(*values) - boundname_val = None - if ( - fields - and fields[-1].name == "boundname" - and len(values) > aux_idx - and isinstance(values[-1], str) - ): - boundname_val = values[-1] - values = values[:-1] - before = values[:aux_idx] - aux_vals = tuple(values[aux_idx:]) - if boundname_val is not None: - return row_cls(*before, aux_vals, boundname=boundname_val) - return row_cls(*before, aux_vals) - - -def _keyword_of(cls: type) -> str: - return vars(cls).get("_keyword", "") - - -def _n_fixed_tokens(cls: type) -> int: - """Count of fixed (non-cellid, non-aux, non-boundname) token slots a Row - consumes -- used to infer a variable-width cellid's element count from a - row's total token length. Prefix tokens (e.g. LAK tables' "TAB6 FILEIN") - and a _keyword token count as extra fixed slots; optional fields (whether - or not they're time_series, e.g. EVT's pxdp/petm/petm0, only written when - surf_rate_specified) are not counted since they may be entirely absent - from a given row.""" - n = 1 if _keyword_of(cls) else 0 - for f in _row_fields(cls): - if f.metadata.get("cellid") or f.name in ("aux", "boundname"): - continue - if f.metadata.get("optional"): - continue - n += 1 + len(f.metadata.get("prefix", ())) - if f.metadata.get("inout"): - n += 1 # the FILEIN/FILEOUT token itself - return n - - -def infer_ncelldim(rows: list[list], row_cls: "type[Row]", *, naux: int = 0) -> int: - """Infer a Row class's cellid width from the first non-empty raw row. - - Mirrors the token-counting the old Schema/Column-driven parser used: - total tokens minus fixed columns minus aux minus a trailing boundname - (if present) is the cellid's element count (2 for DISV, 3 for DIS, ...). - """ - if _cellid_field(row_cls) is None: - return 0 - first = next((r for r in rows if r), None) - if not first: - return 0 - n_fixed = _n_fixed_tokens(row_cls) - last = first[-1] - has_bn = isinstance(last, str) and not _token_fits(last, float) - return max(1, len(first) - n_fixed - naux - (1 if has_bn else 0)) - - -def _token_fits(token: Any, kind: type) -> bool: - """True if token can be coerced to kind (int/float) -- used to tell a - trailing boundname string apart from a trailing numeric value.""" - if isinstance(token, (int, float)): - return True - if isinstance(token, str): - try: - float(token) - return True - except ValueError: - return False - return False - - -def _coerce_scalar(token: Any, f: attrs.Attribute) -> Any: - """Cast a raw token to a field's declared scalar type. - - time_series fields accept either a float or a time-series-name string - (float() failure falls back to the raw string, matching how the old - Column(dtype="np.object_", time_series=True) path worked). - """ - if f.metadata.get("time_series"): - try: - return float(token) - except (ValueError, TypeError): - return str(token) - t = f.type - if t in (int, "int"): - return int(float(str(token))) - if t in (float, "float"): - return float(token) - if t in (Path, "Path"): - return Path(token) - return token - - -class Row: - """Mixin for generated table-row types (plain rows and keystring-union - arms alike -- see module docstring for the _keyword distinction).""" - - def to_row(self) -> tuple: - """Serialize this row to an MF6 token tuple. - - pk/fk fields convert back to 1-based; cellid likewise, per element. - If the class declares _keyword, that token is emitted immediately - before the first non-index field (matching where MF6 places a - keystring arm's discriminator: after any leading feature index, - before the arm's own data). aux and boundname are always last. - """ - cls = type(self) - fields = _row_fields(cls) - keyword = _keyword_of(cls) - row: list[Any] = [] - keyword_emitted = not keyword - for f in fields: - if f.name in ("aux", "boundname"): - continue - val = getattr(self, f.name) - if val is None: - continue - if f.metadata.get("cellid"): - row.extend(int(c) + 1 for c in val) - elif f.metadata.get("pk") or f.metadata.get("fk"): - row.append(int(val) + 1) - elif f.metadata.get("tagged"): - # Inline optional keyword (e.g. MIXED): emit the token only - # when truthy, same convention record.py's Record uses. - if not keyword_emitted: - row.append(keyword.upper()) - keyword_emitted = True - if val: - row.append(f.name.upper()) - else: - if not keyword_emitted: - row.append(keyword.upper()) - keyword_emitted = True - if prefix := f.metadata.get("prefix"): - row.extend(prefix) - if inout := f.metadata.get("inout"): - row.append("FILEOUT" if inout == "fileout" else "FILEIN") - row.append(str(val) if isinstance(val, Path) else val) - if not keyword_emitted: - row.append(keyword.upper()) - aux = getattr(self, "aux", None) - if aux: - row.extend(aux) - boundname = getattr(self, "boundname", None) - if boundname: - row.append(boundname) - return tuple(row) - - @classmethod - def from_row( - cls, tokens: list, *, ncelldim: int = 0, naux: int = 0, boundnames: bool = False - ) -> "Row": - """Parse one raw token row into a Row instance (mirror of to_row). - - Required fields are always present and consumed unconditionally. - Optional, non-tagged value columns (e.g. EVT's pxdp/petm/petm0) have - no self-identifying marker token -- MF6 writes all of a package's - such trailing columns together or none at all, gated by some - OPTIONS-block flag (e.g. SURF_RATE_SPECIFIED), so presence can't be - decided per-field by just checking "any tokens left" (that would - misassign aux/boundname tokens to them when they're actually - absent). Instead, the number present is inferred once from the - total remaining token budget after reserving aux/boundname slots, - then that many are taken off the front, in declared order. Tagged - (inline_keyword) optional fields are self-describing -- they peek - at their own keyword token -- and don't consume from that budget. - """ - fields = _row_fields(cls) - keyword = _keyword_of(cls) - keyword_skipped = not keyword - has_boundname = _has_boundname_field(cls) and boundnames - has_aux = _has_aux_field(cls) - kwargs: dict[str, Any] = {} - tok_idx = 0 - n = len(tokens) - - def consume(f: attrs.Attribute) -> None: - nonlocal tok_idx, keyword_skipped - if f.metadata.get("cellid"): - cellid = tuple(int(tokens[tok_idx + j]) - 1 for j in range(ncelldim)) - kwargs[f.name] = cellid - tok_idx += ncelldim - return - if f.metadata.get("pk") or f.metadata.get("fk"): - kwargs[f.name] = int(float(str(tokens[tok_idx]))) - 1 - tok_idx += 1 - return - if not keyword_skipped: - tok_idx += 1 - keyword_skipped = True - if prefix := f.metadata.get("prefix"): - tok_idx += len(prefix) - if f.metadata.get("inout"): - tok_idx += 1 # skip the FILEIN/FILEOUT token itself - if tok_idx >= n: - return - kwargs[f.name] = _coerce_scalar(tokens[tok_idx], f) - tok_idx += 1 - - def width(f: attrs.Attribute) -> int: - w = 1 + len(f.metadata.get("prefix", ())) - if f.metadata.get("inout"): - w += 1 - return w - - main_fields = [f for f in fields if f.name not in ("aux", "boundname")] - required_fields = [f for f in main_fields if not f.metadata.get("optional")] - optional_fields = [f for f in main_fields if f.metadata.get("optional")] - - for f in required_fields: - consume(f) - - has_bn_token = False - if has_boundname and n > tok_idx: - last = tokens[-1] - has_bn_token = isinstance(last, str) and not _token_fits(last, float) - remaining = n - tok_idx - (1 if has_bn_token else 0) - (naux if has_aux else 0) - - budget_fields = [f for f in optional_fields if not f.metadata.get("tagged")] - n_opt_present = 0 - used = 0 - for f in budget_fields: - w = width(f) - if used + w > remaining: - break - used += w - n_opt_present += 1 - - budget_idx = 0 - for f in optional_fields: - if f.metadata.get("tagged"): - if not keyword_skipped: - tok_idx += 1 - keyword_skipped = True - kw = f.name.upper() - if tok_idx < n and str(tokens[tok_idx]).upper() == kw: - kwargs[f.name] = str(tokens[tok_idx]) - tok_idx += 1 - continue - present = budget_idx < n_opt_present - budget_idx += 1 - if present: - consume(f) - if not keyword_skipped: - tok_idx += 1 - - if has_aux: - aux_vals = [] - end = n - (1 if has_bn_token else 0) - while tok_idx < end: - tok = tokens[tok_idx] - try: - aux_vals.append(float(tok)) - except (ValueError, TypeError): - aux_vals.append(tok) - tok_idx += 1 - kwargs["aux"] = tuple(aux_vals) - - if has_bn_token: - kwargs["boundname"] = str(tokens[-1]) - - return cls(**kwargs) - - -def _unwrap_row_item(item) -> "type[Row] | tuple[type[Row], ...] | None": - """A single Row subclass, or a tuple of Row subclasses for a Union - (keystring-arm) item type -- e.g. ``LakStatusItem | LakStageItem``.""" - if isinstance(item, type) and issubclass(item, Row): - return item - origin = get_origin(item) - if origin is Union or origin is types.UnionType: - arms = tuple(a for a in get_args(item) if isinstance(a, type) and issubclass(a, Row)) - return arms or None - return None - - -def row_list_type(field_type) -> "type[Row] | tuple[type[Row], ...] | None": - """If field_type is Optional[list[C]] or Optional[dict[int, list[C]]], - return C -- or, for a Union item type (keystring arms), the tuple of arm - classes. The generated field's own type annotation is the schema now -- - no separate Schema/Column lookup.""" - args = get_args(field_type) - inner = next((a for a in args if a is not type(None)), None) - if inner is None: - return None - origin = get_origin(inner) - if origin is list: - return _unwrap_row_item(get_args(inner)[0]) - if origin is dict: - _, val = get_args(inner) - if get_origin(val) is list: - return _unwrap_row_item(get_args(val)[0]) - return None - - -def dispatch_union_row(row: list, arm_classes: "tuple[type[Row], ...]") -> "type[Row] | None": - """Find which arm class a raw token row belongs to, by locating the - first token that matches one of the arms' _keyword tokens.""" - kw_map = {_keyword_of(c).upper(): c for c in arm_classes if _keyword_of(c)} - for t in row: - arm_cls = kw_map.get(str(t).upper()) - if arm_cls is not None: - return arm_cls - return None - - -def parse_union_rows( - rows: list, arm_classes: "tuple[type[Row], ...]", *, naux: int = 0, boundnames: bool = False -) -> list | None: - """Parse raw token rows into a list of Row instances, dispatching each - row to the correct arm class by its keyword token (see - dispatch_union_row). Rows matching no known keyword are skipped.""" - if not rows: - return None - result = [] - for row in rows: - if not row: - continue - arm_cls = dispatch_union_row(row, arm_classes) - if arm_cls is None: - continue - result.append(arm_cls.from_row(row, naux=naux, boundnames=boundnames)) - return result or None diff --git a/flopy4/mf6/spec.py b/flopy4/mf6/spec.py index 57379bd9..76ae91f7 100644 --- a/flopy4/mf6/spec.py +++ b/flopy4/mf6/spec.py @@ -43,14 +43,14 @@ def field( auto_from: str | None = None, fill_forward: bool = False, reader: str | None = None, - oc_action: str | None = None, - oc_rtype: str | None = None, time_series: bool = False, + index: bool = False, pk: bool = False, fk: str | None = None, cellid: bool = False, tagged: bool = False, prefix: tuple[str, ...] | None = None, + array: bool = False, ): """Define a codegen-v2 field: always a plain ``attrs.field()``. @@ -91,12 +91,10 @@ def field( metadata["fill_forward"] = True if reader: metadata["reader"] = reader - if oc_action: - metadata["oc_action"] = oc_action - if oc_rtype: - metadata["oc_rtype"] = oc_rtype if time_series: metadata["time_series"] = True + if index: + metadata["index"] = True if pk: metadata["pk"] = True if fk: @@ -107,6 +105,8 @@ def field( metadata["tagged"] = True if prefix: metadata["prefix"] = tuple(prefix) + if array: + metadata["array"] = True return attrs.field( default=default, validator=validator, @@ -155,7 +155,7 @@ def xattree_field( ) -FileInOut = Literal[None, "filein", "fileout"] +FileDirection = Literal[None, "in", "out"] def path( @@ -168,7 +168,7 @@ def path( metadata=None, on_setattr=None, block: str | None = None, - inout: FileInOut | None = None, + direction: FileDirection | None = None, longname: str | None = None, optional: bool = False, prefix: tuple[str, ...] | None = None, @@ -177,10 +177,10 @@ def path( ``prefix``: fixed token(s) a row-level path column emits before its own FILEIN/FILEOUT+filename (e.g. LAK tables' ``TAB6``, SSM fileinput's - ``SPC6``) -- read by Row.to_row()/from_row() the same way any other - row column's prefix= is (see flopy4.mf6.row.Row). Package-level path - fields (options-block file records) don't need this -- there's no - preceding row context, just the field's own inout=. + ``SPC6``) -- read by Item.to_tokens()/from_tokens() the same way any + other row column's prefix= is (see flopy4.mf6.item.Item). Package-level + path fields (options-block file records) don't need this -- there's no + preceding row context, just the field's own direction=. See ``field()`` — use ``xattree_path()`` instead for fields on real ``@xattree`` component classes. @@ -190,8 +190,8 @@ def path( metadata["prefix"] = tuple(prefix) if block: metadata["block"] = block - if inout: - metadata["inout"] = inout + if direction: + metadata["direction"] = direction if longname: metadata["longname"] = longname if optional: @@ -218,7 +218,7 @@ def xattree_path( metadata=None, on_setattr=None, block: str | None = None, - inout: FileInOut | None = None, + direction: FileDirection | None = None, longname: str | None = None, ): """Define a path field on a real ``@xattree``-decorated component class. @@ -226,12 +226,12 @@ def xattree_path( See ``field()`` for why this is a separate function rather than a shared one that infers which case applies. """ - if block or inout or longname: + if block or direction or longname: metadata = metadata or {} if block: metadata["block"] = block - if inout: - metadata["inout"] = inout + if direction: + metadata["direction"] = direction if longname: metadata["longname"] = longname return flopy_field( diff --git a/flopy4/mf6/tdis.py b/flopy4/mf6/tdis.py index 08010c8a..2107c79f 100644 --- a/flopy4/mf6/tdis.py +++ b/flopy4/mf6/tdis.py @@ -5,8 +5,8 @@ import numpy as np from numpy.typing import ArrayLike, NDArray +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field from flopy4.mf6.utils.time import Time @@ -16,7 +16,7 @@ class Tdis(Package): dfn_name: ClassVar[str] = "sim-tdis" @attrs.define - class PeriodData(Row): + class PeriodData(Item): perlen: float nstp: int tsmult: float diff --git a/flopy4/mf6/utils/codegen/dfn_overrides.toml b/flopy4/mf6/utils/codegen/dfn_overrides.toml index 8d1a7a35..edf3628c 100644 --- a/flopy4/mf6/utils/codegen/dfn_overrides.toml +++ b/flopy4/mf6/utils/codegen/dfn_overrides.toml @@ -271,53 +271,15 @@ always_emit_blocks = ["sources"] # Replacing the columnar list expansion with three individual Optional[Path] fields # matches the hand-written fmi.py and the actual MF6 input semantics. replace_list_fields = [ - {block = "packagedata", name = "gwfhead", inout = "filein", longname = "gwf head file"}, - {block = "packagedata", name = "gwfbudget", inout = "filein", longname = "gwf budget file"}, - {block = "packagedata", name = "gwfspdis", inout = "filein", longname = "gwf spdis file"}, + {block = "packagedata", name = "gwfhead", direction = "in", longname = "gwf head file"}, + {block = "packagedata", name = "gwfbudget", direction = "in", longname = "gwf budget file"}, + {block = "packagedata", name = "gwfspdis", direction = "in", longname = "gwf spdis file"}, ] [_package_extras."gwt-lkt"] # nlakes comes from the paired gwf-lak model; gwt-lkt has no DIMENSIONS block. packagedata_dim = "nlakes" -extra_period_fields = [ - {keyword = "STATUS", feature_dim = "nlakes", dtype = "string"}, - {keyword = "CONCENTRATION", feature_dim = "nlakes"}, - {keyword = "RAINFALL", feature_dim = "nlakes"}, - {keyword = "EVAPORATION", feature_dim = "nlakes"}, - {keyword = "RUNOFF", feature_dim = "nlakes"}, - {keyword = "EXT-INFLOW", feature_dim = "nlakes"}, -] [_package_extras."gwe-lke"] # nlakes comes from the paired gwf-lak model; gwe-lke has no DIMENSIONS block. packagedata_dim = "nlakes" -extra_period_fields = [ - {keyword = "STATUS", feature_dim = "nlakes", dtype = "string"}, - {keyword = "TEMPERATURE", feature_dim = "nlakes"}, - {keyword = "RAINFALL", feature_dim = "nlakes"}, - {keyword = "EVAPORATION", feature_dim = "nlakes"}, - {keyword = "RUNOFF", feature_dim = "nlakes"}, - {keyword = "EXT-INFLOW", feature_dim = "nlakes"}, -] - -[_package_extras."gwf-lak"] -# packagedata/connectiondata/tables/outlets list blocks are auto-detected from -# the v1 DFN via v1_list_block_names in _build_block_property_specs (no extra_list_blocks needed). -extra_period_fields = [ - # Lake keyword fields — shape (nper, nlakes); always bare keyword name - {keyword = "STATUS", feature_dim = "nlakes", dtype = "string"}, - {keyword = "STAGE", feature_dim = "nlakes"}, - {keyword = "RAINFALL", feature_dim = "nlakes"}, - {keyword = "EVAPORATION", feature_dim = "nlakes"}, - {keyword = "RUNOFF", feature_dim = "nlakes"}, - {keyword = "INFLOW", feature_dim = "nlakes"}, - {keyword = "WITHDRAWAL", feature_dim = "nlakes"}, - # Outlet keyword fields — shape (nper, noutlets); always bare keyword name - # Note: INVERT/WIDTH/SLOPE/ROUGH also appear as outlets packagedata columns; - # those static columns get outlets_ prefix (collision_names with reserved period names). - {keyword = "RATE", feature_dim = "noutlets"}, - {keyword = "INVERT", feature_dim = "noutlets"}, - {keyword = "WIDTH", feature_dim = "noutlets"}, - {keyword = "SLOPE", feature_dim = "noutlets"}, - {keyword = "ROUGH", feature_dim = "noutlets"}, -] diff --git a/flopy4/mf6/utils/codegen/filters.py b/flopy4/mf6/utils/codegen/filters.py index 69e5c56b..36a1e0e2 100644 --- a/flopy4/mf6/utils/codegen/filters.py +++ b/flopy4/mf6/utils/codegen/filters.py @@ -14,6 +14,7 @@ import builtins import keyword +import re from collections import Counter from dataclasses import dataclass from pathlib import Path @@ -243,40 +244,40 @@ def _is_expandable_child(child: FieldV3) -> bool: return isinstance(child, KeywordField) +def _record_child_supported(c: FieldV3) -> bool: + """True if a record child is a scalar/keyword, or a Record whose own + children are (recursively) supported -- arbitrarily deep, since nested + Records now compose as their own classes (make.py's + _build_record_class_specs) rather than needing to flatten into a fixed + depth. Lists and unions still fall back to TODO.""" + if isinstance(c, _RECORD_CLASS_SCALAR_TYPES + (KeywordField,)): + return True + if isinstance(c, Record) and c.fields: + return all(_record_child_supported(gc) for gc in c.fields.values()) + return False + + def can_generate_record_class(f: FieldV3) -> bool: """True when a compound record should be rendered as an inner attrs class. - All non-file records whose children are entirely scalars and/or keywords - become inner attrs classes. The first keyword child (if any) is the - trigger token (``_keyword``); remaining keyword children become - ``Optional[bool]`` fields so related options stay grouped. + All non-file records whose children are entirely scalars, keywords, and/or + nested records (of supported shape, see _record_child_supported) become + inner attrs classes. The first keyword child (if any) is the trigger + token (``_keyword``); remaining keyword children become ``Optional[bool]`` + fields so related options stay grouped. A child that is itself a Record + becomes its own composed class rather than being flattened in (the + `head/temperature/concentration/qoutflow/cim` printrecord family: outer + record composes `formatrecord: Record{columns, width, digits, format}`). All-keyword records with only one child (a lone flag keyword) are left to :func:`can_expand_record` -- a bare bool field is cleaner there than an empty inner class. Records with unsupported child types (list, union) - fall back to TODO comments. A child that is itself a Record is supported - one level deep, provided *its* children are all scalar/keyword too (the - `head/temperature/concentration/qoutflow/cim` printrecord family: outer - record wraps `formatrecord: Record{columns, width, digits, format}`) -- - its fields are flattened into the same inner class (see - make.py._build_inner_class_spec). Deeper nesting falls back to TODO. + fall back to TODO comments. """ if not isinstance(f, Record) or is_file_record(f) or not f.fields: return False children = list(f.fields.values()) - - def _supported(c: FieldV3) -> bool: - if isinstance(c, _RECORD_CLASS_SCALAR_TYPES + (KeywordField,)): - return True - if isinstance(c, Record) and c.fields: - return all( - isinstance(gc, _RECORD_CLASS_SCALAR_TYPES + (KeywordField,)) - for gc in c.fields.values() - ) - return False - - all_supported = all(_supported(c) for c in children) - if not all_supported: + if not all(_record_child_supported(c) for c in children): return False has_scalar = any(isinstance(c, _RECORD_CLASS_SCALAR_TYPES) for c in children) # All-keyword records need at least 2 children (trigger + modifier) to @@ -451,9 +452,9 @@ def field_metadata(f: FieldV3, block_name: str, *, has_maxbound: bool = False) - if is_file_record(f): child = file_child(f) assert child is not None # is_file_record() already confirmed a File child exists - kw["inout"] = "filein" if child.direction == "in" else "fileout" + kw["direction"] = child.direction elif is_bare_file(f): - kw["inout"] = "filein" if f.direction == "in" else "fileout" + kw["direction"] = f.direction return kw @@ -525,21 +526,34 @@ def python_repr(v) -> str: return "\n".join(lines) -def row_class( - schema_list: list[dict], class_name: str, is_period: bool = False, has_aux: bool = False +def pascal_name(name: str) -> str: + """snake_case, hyphenated, or a plain lowercase word -> PascalCase, e.g. + ``stress_period_data`` -> ``StressPeriodData``, ``packagedata`` -> + ``Packagedata``, ``ext-inflow`` (a real MF6 keystring keyword, LKT/LKE) + -> ``ExtInflow``. Only "_"/"-" split words -- the wire keyword itself + (unlike the class name) keeps its original separator, since a hyphen is + fine inside a Python string but not an identifier.""" + return "".join(part.capitalize() for part in re.split(r"[_-]", name)) + + +def item_class( + schema_list: list[dict], + class_name: str, + is_period: bool = False, + has_aux: bool = False, + keyword: str = "", ) -> str: - """Render a Row subclass (flopy4.mf6.row.Row) for list block construction. + """Render an Item subclass (flopy4.mf6.item.Item) for list block construction. Called as:: - {{ spec.period_schema | row_class("Row", True) }} - {{ block_schema | row_class("PackagedataRow") }} + {{ spec.period_schema | item_class("StressPeriodData", True) }} + {{ block_schema | item_class("Packagedata") }} + {{ arm_schema | item_class("Status", keyword="STATUS") }} Produces a 4-space-indented ``@attrs.define`` class whose fields carry - real metadata (``pk=``/``fk=``/``cellid=``/``time_series=``/``prefix=``/ - ``tagged=``, via ``field()``) -- the class itself is the schema; - structure.py/unstructure.py introspect it directly (see flopy4.mf6.row). - No separate Schema/Column description is emitted. + real metadata (``index=``/``pk=``/``fk=``/``cellid=``/``time_series=``/ + ``prefix=``/``tagged=``, via ``field()``) -- the class itself is the schema. Required fields (no default) are declared before optional fields to satisfy attrs ordering constraints. @@ -549,8 +563,16 @@ def row_class( columns in their stress period rows. Static list blocks (packagedata, connectiondata, etc.) have fixed DFN schemas and never carry dynamic aux columns, so ``is_period`` should be False (the default) for those. + + ``keyword``, when given, is one arm of a keystring-union period field + (e.g. LAK's STAGE/RATE/STATUS settings, OC's SAVE/PRINT records) -- + emitted as ``_keyword`` so flopy4.mf6.item.dispatch_union_item can pick + the right arm class for a raw row by its leading token. An arm can be a + bare keyword with no data of its own (e.g. PRP's releasesetting ALL/ + FIRST/LAST) -- schema_list is then empty, but the class itself (just + _keyword) is still real and must still be emitted. """ - if not schema_list: + if not schema_list and not keyword: return "" _DFN_PY: dict[str, str] = { @@ -574,6 +596,8 @@ def _py_type(col: dict) -> str: return "object" if role == "boundname": return "str" + if role == "array": + return "tuple" if col.get("time_series") or col.get("dtype") == "np.object_": return "Union[float, str]" return _DFN_PY.get(col.get("dfn_type", "double"), "float") @@ -587,16 +611,17 @@ def _is_optional(col: dict) -> bool: "boundname", "inline_keyword", "keystring_value", + "array", ) - def _prefix_inout(col: dict) -> str: - """MF6 inout direction implied by a row column's prefix tokens.""" - return "fileout" if "FILEOUT" in (col.get("prefix") or "").upper().split() else "filein" + def _prefix_direction(col: dict) -> str: + """MF6 file direction implied by a row column's prefix tokens.""" + return "out" if "FILEOUT" in (col.get("prefix") or "").upper().split() else "in" def _prefix_tokens(col: dict) -> tuple: """Fixed literal prefix token(s) preceding FILEIN/FILEOUT itself (e.g. SSM fileinput's "SPC6", LAK tables' "TAB6") -- the FILEIN/FILEOUT - keyword is handled separately via inout=, not part of this tuple.""" + keyword is handled separately via direction=, not part of this tuple.""" parts = (col.get("prefix") or "").split() return tuple(p for p in parts if p not in ("FILEIN", "FILEOUT")) @@ -606,16 +631,19 @@ def _field_meta(col: dict) -> dict: if role == "cellid": meta["cellid"] = True elif role == "feature_id": + meta["index"] = True if col.get("fk"): meta["fk"] = col["fk"] - else: + elif col.get("pk"): meta["pk"] = True elif role == "inline_keyword": meta["tagged"] = True + elif role == "array": + meta["array"] = True if col.get("time_series"): meta["time_series"] = True if _is_optional(col): - # Needed even for time_series fields: _n_fixed_tokens() (row.py) + # Needed even for time_series fields: _n_fixed_tokens() (item.py) # uses this to tell "always present" fixed columns apart from # trailing columns that may be entirely absent from a given row # (e.g. EVT's pxdp/petm/petm0, only written when @@ -625,22 +653,31 @@ def _field_meta(col: dict) -> dict: return meta def _field_line(col: dict, *, optional: bool) -> str: + if col["role"] == "array": + # Consumes all remaining tokens as a tuple (see item.py's + # from_tokens/to_tokens "array" metadata handling) -- a + # keyword-plus-trailing-values setting whose arity/type isn't + # fixed (OC/PRP's ocsetting/releasesetting), not a single value. + return f" {col['name']}: tuple = field(default=(), array=True)" # File-reference columns (a fixed MF6 token or two before a filename, # e.g. LAK tables' "TAB6 FILEIN ") are Path fields built via the # same path() convention used for Package-level file fields, not the # generic dtype-based Union[float, str] fallback below. if col.get("prefix"): - inout = _prefix_inout(col) + direction = _prefix_direction(col) fixed = _prefix_tokens(col) prefix_kw = f", prefix={_dq(fixed)}" if fixed else "" if optional: return ( f" {col['name']}: Optional[Path] = path(\n" f" default=None, converter=_optional_path, " - f'inout="{inout}"{prefix_kw}\n' + f'direction="{direction}"{prefix_kw}\n' f" )" ) - return f' {col["name"]}: Path = path(converter=Path, inout="{inout}"{prefix_kw})' + return ( + f" {col['name']}: Path = path(converter=Path, " + f'direction="{direction}"{prefix_kw})' + ) py_type = _py_type(col) meta = _field_meta(col) margs = ", ".join(f"{k}={_dq(v)}" for k, v in meta.items()) @@ -683,15 +720,9 @@ def _field_line(col: dict, *, optional: bool) -> str: boundname_cols = [col for col in optional if col["role"] == "boundname"] lines = [" @attrs.define"] - # class_name == "Row" (the period-block case) needs the base written as - # the aliased `_Row` (imported as `Row as _Row`, see make.py): plain - # `class Row(Row):` makes the base unresolvable to mypy (the name gets - # shadowed by the class being defined before the base expression is - # "seen"), even though Python itself resolves it fine at runtime. Other - # row classes (PackagedataRow, ConnectiondataRow, ...) don't collide - # with the import name, so they stay on plain `Row`. - _row_base = "_Row" if class_name == "Row" else "Row" - lines.append(f" class {class_name}({_row_base}):") + lines.append(f" class {class_name}(Item):") + if keyword: + lines.append(f' _keyword: ClassVar[str] = "{keyword}"') for col in required: lines.append(_field_line(col, optional=False)) for col in optional_non_boundname: @@ -724,7 +755,7 @@ class ColumnSpec: is_cellid: bool # shape=["ncelldim"] -- stored as object-dtype tuple attr is_prefix: bool # tagged non-optional keyword -- write-side token only, no attr is_row_keyword: bool # optional keyword -- stored as bool attr - is_index: bool # pk or fk column: 0-based index written as 1-based (+1 at write time) + is_index: bool # dev3 Integer.index -- 0-based, written as 1-based (+1 at write time) def find_keystring_union(list_field: ListField) -> UnionField | None: @@ -755,6 +786,47 @@ def find_keystring_union(list_field: ListField) -> UnionField | None: return None +def _fields_to_columns( + fields: "list[tuple[str, FieldV3]]", component_name: str = "" +) -> list[ColumnSpec]: + """Build ColumnSpecs from an ordered (name, field) sequence -- the shared + core of list_columns (a List[Record]'s own item fields) and + make.py's keystring-union arm processing (a Union arm's fields, once its + own leading keyword, if any, is split off as the arm's _keyword). + + ``safe_name`` sanitizes the column name (e.g. LKT/LKE's hyphenated + "ext-inflow" arm -> "ext_inflow") since it becomes a Python attribute + name here -- unlike a class's own _keyword string, which keeps its + original spelling (it's compared against a raw wire token, not used as + an identifier). + """ + result = [] + for col_name, raw_col in fields: + col = apply_override(component_name, raw_col) if component_name else raw_col + is_keyword = isinstance(col, KeywordField) + is_optional = col.optional + result.append( + ColumnSpec( + name=safe_name(col_name), + field=col, + is_cellid=isinstance(col, Array) and list(col.shape or []) == ["ncelldim"], + is_prefix=is_keyword and not is_optional, + is_row_keyword=is_keyword and is_optional, + # role="feature_id" implies MF6's numeric 0-based-Python/1-based- + # file conversion (structure.py: int(...) - 1). dev3's `index` + # attribute (split out of the old overloaded pk/fk semantics, + # modflow-devtools 41dca93) is now the direct, authoritative + # signal for this -- no longer inferred from pk/fk-ness (a + # string pk/fk, e.g. MVR's `pname`, a package *name* reference + # not a numeric one, is never `index`, so the old + # isinstance(col, Integer)-guarded pk-or-fk heuristic this + # replaced is no longer needed either). + is_index=bool(getattr(col, "index", False)), + ) + ) + return result + + def list_columns(f: ListField, component_name: str = "") -> list[ColumnSpec]: """Return the leaf column specs of a dev3 List[Record] field, in order. @@ -772,27 +844,7 @@ def list_columns(f: ListField, component_name: str = "") -> list[ColumnSpec]: item = f.item if not isinstance(item, Record): return [] - result = [] - for col_name, raw_col in item.fields.items(): - col = apply_override(component_name, raw_col) if component_name else raw_col - is_keyword = isinstance(col, KeywordField) - is_optional = col.optional - result.append( - ColumnSpec( - name=col_name, - field=col, - is_cellid=isinstance(col, Array) and list(col.shape or []) == ["ncelldim"], - is_prefix=is_keyword and not is_optional, - is_row_keyword=is_keyword and is_optional, - # role="feature_id" implies MF6's numeric 0-based-Python/1-based- - # file conversion (structure.py: int(...) - 1) -- only sound for - # integer indices. String pk/fk (e.g. MVR's `pname`, a package - # *name* reference, not a numeric one) must stay role="value". - is_index=isinstance(col, Integer) - and bool(getattr(col, "pk", False) or getattr(col, "fk", None)), - ) - ) - return result + return _fields_to_columns(list(item.fields.items()), component_name) def is_keystring_list(f: ListField) -> bool: diff --git a/flopy4/mf6/utils/codegen/make.py b/flopy4/mf6/utils/codegen/make.py index b2aa31ed..00f595ec 100644 --- a/flopy4/mf6/utils/codegen/make.py +++ b/flopy4/mf6/utils/codegen/make.py @@ -17,6 +17,7 @@ import jinja2 from modflow_devtools.dfns.schema import ( + Array, Component, Double, Integer, @@ -31,7 +32,7 @@ ) from . import filters -from .filters import ColumnSpec, FieldV3, _dq, python_repr, row_class +from .filters import ColumnSpec, FieldV3, _dq, item_class, pascal_name, python_repr from .overrides import ( always_emit_blocks, block_dim_override, @@ -66,6 +67,7 @@ class InnerClassFieldSpec: type_annotation: str tagged: bool optional: bool + nested: bool = False # composes another generated Record class, see below @dataclass @@ -95,6 +97,16 @@ class BlockPropertySpec: attr_name_map: dict[str, str] # col_name → Python attr name (bare or block-prefixed) +@dataclass +class PeriodArmSpec: + """Pre-computed context for one keystring-union period arm's generated + Item class (e.g. LAK's Stage/Rate/Status, OC's Saverecord/Printrecord).""" + + class_name: str + keyword: str # lowercase, matches Record's _keyword convention + schema: list[dict] + + @dataclass class ComponentSpec: """Pre-computed context for a generated component class.""" @@ -110,9 +122,9 @@ class ComponentSpec: outpath: Path block_properties: list[BlockPropertySpec] = dc_field(default_factory=list) period_schema: list[dict] = dc_field(default_factory=list) + period_arms: list[PeriodArmSpec] = dc_field(default_factory=list) block_schemas: dict[str, list[dict]] = dc_field(default_factory=dict) has_maxbound: bool = False - has_keystring_period: bool = False has_griddata: bool = False has_readarray_period: bool = False @@ -122,7 +134,7 @@ class ComponentSpec: # Both static list blocks and standard (non-keystring) period blocks are # List[Record] under dev3 -- including a real cellid Array field for period # blocks, which the legacy schema had to synthesize. One function builds the -# list[dict] "schema" (the intermediate format row_class/schema_class render) +# list[dict] "schema" (the intermediate format item_class/schema_class render) # for both cases; only the surrounding FieldSpec (type annotation, block= # vs fill_forward= metadata) differs between them. @@ -157,12 +169,25 @@ def _schema_dict_from_columns(columns: list[ColumnSpec]) -> list[dict]: entry["role"] = "feature_id" if getattr(f, "fk", None): entry["fk"] = f.fk + elif getattr(f, "pk", False): + entry["pk"] = True elif col.name == "boundname": entry["role"] = "boundname" entry["dtype"] = "np.object_" elif col.is_row_keyword: entry["role"] = "inline_keyword" entry["optional"] = True + elif isinstance(f, UnionField) or (isinstance(f, Array) and not getattr(f, "shape", None)): + # A union nested inside a keystring-union arm (OC's ocsetting, + # PRP's releasesetting) or a bare *unbounded* array arm (PRP's + # STEPS n1 n2 ..., shape=[] meaning "however many follow") -- + # keyword-plus-trailing-values, not a single value; consumes all + # remaining tokens as a tuple. A *named*-dimension array (e.g. + # EVT's pxdp/petm, shape=["nseg-1"]) is a fixed-length column + # like any other, not this catch-all -- is_cellid (shape= + # ["ncelldim"]) was already handled above as the other named- + # dimension case. + entry["role"] = "array" elif isinstance(f, String): entry["role"] = "value" entry["dtype"] = "np.object_" @@ -188,6 +213,12 @@ def _dfn_type_str(f: FieldV3) -> str: return "string" if isinstance(f, KeywordField): return "keyword" + if isinstance(f, UnionField): + # A union nested inside a keystring-union arm (e.g. OC's ocsetting, + # PRP's releasesetting -- ALL/FIRST/LAST/FREQUENCY/STEPS) isn't + # itself modeled as a typed sub-union yet; represented as a single + # flexible value column instead (see _build_period_arm_specs). + return "object" return getattr(f, "dtype", "double") # Array @@ -278,7 +309,7 @@ def _ml_field( Produces continuation lines pre-indented at 8 spaces (args) and 4 spaces (closing paren) so the Jinja template can render it verbatim after `` {name}: {type} = ``. ``metadata`` here is the set of ``field()``/ - ``path()`` kwargs (block, schema, oc_action, ...), not a raw attrs + ``path()`` kwargs (block, schema, fill_forward, ...), not a raw attrs metadata dict -- codegen-v2 fields are plain attrs fields, so they go through the same passive-metadata constructors hand-written xattree classes use for their scalar fields. @@ -299,89 +330,78 @@ def _ml_field( return "\n".join(lines) -# OC-family period record expansion: List[Union[saverecord, printrecord]], -# each arm a Record with a real `rtype` field whose `.valid` gives the -# rtype vocabulary natively -- replaces the legacy hardcoded _OC_RTYPES table. - - -def _is_oc_style_union(item: FieldV3) -> bool: - """True for a List whose (unwrapped) item is a Union of rtype-bearing - Records -- the gwf/gwt/gwe/prt-oc saverecord/printrecord shape.""" - return ( - isinstance(item, UnionField) - and bool(item.arms) - and all(isinstance(arm, Record) and "rtype" in arm.fields for arm in item.arms.values()) - ) - - -def _is_index(f: FieldV3) -> bool: - # Integer-only: a string pk/fk (e.g. a name reference) isn't a numeric - # index needing the 0-based/1-based conversion "feature_id" implies. - return isinstance(f, Integer) and bool(getattr(f, "pk", False) or getattr(f, "fk", None)) - - -def _keystring_has_index(list_field: FieldV3, union: UnionField) -> bool: - """True if a keystring-shaped period list has a per-row feature index. - - Two shapes carry one: an outer sibling index field next to the union - (LKE/LKT/SFR-style: item Record = {lakeno: Integer(fk=...), setting: - Union}), or a pk/fk field embedded in every arm (LAK-style: item Record - wraps the union alone, each arm starts with its own lakeno/outletno). - PRP's `releasesetting` (ALL/FIRST/LAST/FREQUENCY/STEPS) has neither -- - confirmed via the v1 DFN, which declares it a bare `recarray - releasesetting` with no index field at all, matching MF6IO syntax with - no leading row number. Emitting a fabricated "number" column there would - be wrong, not just redundant. +def _build_period_arm_specs( + list_field: FieldV3, union: UnionField, component_name: str, used_names: set[str] +) -> list[PeriodArmSpec]: + """Build one PeriodArmSpec per keystring-union arm, from the union's own + real per-arm structure (Union.arms carries real fields natively under + dev3) -- each arm becomes its own typed Item class, dispatched at parse + time by its leading keyword (see item.py's dispatch_union_item), instead + of the old generic (index?, keyword, value) placeholder that collapsed + every arm's real shape into one untyped "value" column. + + Handles all three index shapes seen in the corpus generically, via a + shared prefix of columns prepended to every arm: + - OC-style: the List's item IS the union directly (no index at all). + - LAK-style: each arm embeds its own fk index (lakeno/outletno) as one + of its own fields -- no shared prefix needed, it falls out of the + arm's own fields. + - SFR/MAW-style: the union is a sibling of an outer index field (item + Record = {ifno, ...setting: Union}) -- shared by every arm. + + An arm field that's itself a union (OC's ocsetting, PRP's + releasesetting -- ALL/FIRST/LAST/FREQUENCY/STEPS) isn't recursively + exploded into its own typed sub-arms; it becomes a single flexible + value column (see _dfn_type_str/_schema_dict_from_columns), the same + reduced fidelity the rest of the corpus already accepts for + time_series-style ambiguous values. Only the outer dispatch (which arm + -- SAVE vs PRINT, STAGE vs RATE, ...) needs to be real for this to + reflect the DFN's actual structure; that's what a user constructs and + dispatches on. """ item = list_field.item - if isinstance(item, Record): - if any(f is not union and _is_index(f) for f in item.fields.values()): - return True - return any( - isinstance(arm, Record) and any(_is_index(f) for f in arm.fields.values()) - for arm in union.arms.values() + shared_cols: list[tuple[str, FieldV3]] = ( + [(n, f) for n, f in item.fields.items() if f is not union] + if isinstance(item, Record) + else [] ) - -def _oc_rtypes(item: UnionField) -> list[str]: - """Valid rtype strings for an OC-style union, read from the schema.""" - rtypes: list[str] = [] - for arm in item.arms.values(): - for v in arm.fields["rtype"].valid or []: - if v not in rtypes: - rtypes.append(v) - return rtypes - - -def _oc_action(item: UnionField, arm_name: str) -> str: - """'save' or 'print', from the arm's leading trigger keyword.""" - arm = item.arms[arm_name] - trigger = next(iter(arm.fields.values())) - return "save" if isinstance(trigger, KeywordField) and trigger.name == "save" else "print" - - -def _expand_oc_record_field(list_field: FieldV3) -> list[FieldSpec]: - """Expand an OC-style period list field into per-rtype period fields.""" - item = filters.find_keystring_union(list_field) - assert item is not None # caller already confirmed this is an OC-style union field - rtypes = _oc_rtypes(item) - specs: list[FieldSpec] = [] - for arm_name in item.arms: - action = _oc_action(item, arm_name) - for rtype in rtypes: - py_name = f"{action}_{rtype.lower()}" - spec_call = _ml_field( - metadata={"block": "period", "oc_action": action, "oc_rtype": rtype.lower()} - ) - specs.append( - FieldSpec( - dfn_name=f"{arm_name}_{rtype.lower()}", - py_name=py_name, - type_annotation="Optional[dict[int, list[str]]]", - spec_call=spec_call, - generatable=True, - ) + specs: list[PeriodArmSpec] = [] + for arm_name, arm in union.arms.items(): + if isinstance(arm, Record): + # The discriminating keyword isn't always the arm's first field -- + # LAK's auxiliaryrecord is (lakeno, auxiliary(kw), auxname, auxval), + # its own per-arm index leading the keyword. Find the first + # KeywordField anywhere; everything else (including any leading + # index) is a real column. A second required keyword later (e.g. + # SFR's cross_sectionrecord: cross_section(kw), tab6(kw), ...) is + # left in `rest` and becomes a per-field prefix=, not _keyword. + arm_fields = list(arm.fields.items()) + kw_idx = next( + (i for i, (_, fld) in enumerate(arm_fields) if isinstance(fld, KeywordField)), None ) + if kw_idx is not None: + keyword = arm_fields[kw_idx][0] + rest = arm_fields[:kw_idx] + arm_fields[kw_idx + 1 :] + else: + keyword = "_".join(_strip_record_words(arm_name)) + rest = arm_fields + elif isinstance(arm, KeywordField): + # A bare keyword arm carries no data of its own (PRP's + # releasesetting ALL/FIRST/LAST) -- the keyword IS the entire row. + keyword = "_".join(_strip_record_words(arm_name)) + rest = [] + else: + keyword = "_".join(_strip_record_words(arm_name)) + rest = [(arm_name, arm)] + + cols = filters._fields_to_columns(shared_cols + rest, component_name) + schema = _schema_dict_from_columns(cols) + class_name = pascal_name("_".join(_strip_record_words(arm_name))) + if class_name in used_names: + class_name = pascal_name("_".join(_strip_record_words(list_field.name))) + class_name + used_names.add(class_name) + specs.append(PeriodArmSpec(class_name=class_name, keyword=keyword, schema=schema)) return specs @@ -402,8 +422,12 @@ def _strip_record_words(name: str) -> list[str]: return [w for w in words if w] -def _build_inner_class_spec(f: Record, dfn_name: str) -> InnerClassSpec: - """Build an InnerClassSpec for a mixed-type compound record field. +def _build_record_class_specs( + f: Record, dfn_name: str, used_names: set[str], *, parent_hint: str = "" +) -> list[InnerClassSpec]: + """Build InnerClassSpecs for a compound record field and any nested + Record children, in dependency order (nested classes first, so a later + class can reference an earlier one). When the first child is a keyword type it becomes the trigger token (``_keyword``) and is not emitted as a data field. When the first child @@ -414,6 +438,19 @@ def _build_inner_class_spec(f: Record, dfn_name: str) -> InnerClassSpec: (always emitted, not user-facing fields) stored in ``_extra_tokens``. Optional keyword children become Optional[bool] fields. + A child that's itself a Record composes as its own class (recursing + here) rather than flattening its fields into this one: the field gets a + forward-reference string type annotation (qualified with the enclosing + package class name, e.g. ``"Oc.Format"``, so mypy's scope analysis can + resolve it too), since generated inner classes render as flat siblings + inside the package class regardless of DFN nesting depth, and Python + class bodies can't see sibling names at class-body-execution time. + record.py's Record.from_tokens infers which fields are composed from + that annotation directly (via _nested_class) -- no declared flag needed. + `used_names` disambiguates two different fields whose nested child + happens to share a name (e.g. two unrelated "formatrecord" wrappers) by + prefixing the second with `parent_hint`. + Extra children from ``dfn_overrides.toml`` (used to inject fields not yet representable, e.g. positional sub-record fields) are appended after the direct children. All fields are sorted required-first to satisfy attrs. @@ -429,6 +466,7 @@ def _build_inner_class_spec(f: Record, dfn_name: str) -> InnerClassSpec: extra_tokens: list[str] = [] inner_fields: list[InnerClassFieldSpec] = [] + nested_specs: list[InnerClassSpec] = [] def _process_child(child: FieldV3) -> None: child = apply_override(dfn_name, child) @@ -436,14 +474,17 @@ def _process_child(child: FieldV3) -> None: tagged = getattr(child, "tagged", False) if isinstance(child, Record): - # One level of nesting (the head/temperature/concentration/ - # qoutflow/cim printrecord family: formatrecord wraps columns/ - # width/digits/format) -- flatten the nested record's own fields - # into this same inner class rather than emitting a second class. - # can_generate_record_class already confirmed all grandchildren - # are scalar/keyword-only. - for nested in child.fields.values(): - _process_child(nested) + child_specs = _build_record_class_specs(child, dfn_name, used_names, parent_hint=f.name) + nested_specs.extend(child_specs) + inner_fields.append( + InnerClassFieldSpec( + py_name=filters.safe_name(child.name), + type_annotation=child_specs[-1].class_name, # bare; template qualifies it + tagged=False, + optional=is_optional, + nested=True, + ) + ) elif isinstance(child, KeywordField): if not is_optional: # Required keyword: always emitted as a fixed syntax token. @@ -513,17 +554,21 @@ def _process_child(child: FieldV3) -> None: inner_fields.sort(key=lambda field: str(field.optional)) words = _strip_record_words(f.name) - class_name = "".join(w.capitalize() for w in words) + class_name = "".join(w.capitalize() for w in words) or "Record" + if class_name in used_names and parent_hint: + class_name = "".join(w.capitalize() for w in _strip_record_words(parent_hint)) + class_name + used_names.add(class_name) extra_tokens_repr = ( "(" + ", ".join(f'"{t}"' for t in extra_tokens) + ",)" if extra_tokens else "" ) - return InnerClassSpec( + this_spec = InnerClassSpec( class_name=class_name, keyword=kw, extra_tokens=extra_tokens, extra_tokens_repr=extra_tokens_repr, fields=inner_fields, ) + return nested_specs + [this_spec] def _period_keystring_names(component: Component) -> frozenset[str]: @@ -647,6 +692,7 @@ def _new_codegen_imports( has_field_call: bool = False, has_path_call: bool = False, period_schema: list[dict] | None = None, + period_arms: "list[PeriodArmSpec] | None" = None, block_schemas: dict[str, list[dict]] | None = None, ) -> dict[str, list[str]]: """Compute import lines for new-codegen packages (no xattree, no spec calls).""" @@ -664,30 +710,33 @@ def _new_codegen_imports( or has_inner_classes or has_period_schema or bool(block_schemas) + or bool(period_arms) or has_readarray_period or has_injected_paths # injected path fields are always Optional[Path] ) # dfn_name is always emitted as a ClassVar (see package.py.jinja), so # ClassVar is always needed regardless of multi/slntype/inner classes. has_classvar = True - # Union[float, str] is used by row_class() for time_series and np.object_ columns. - # Check both the period schema and all static block schemas. - _all_schema_cols = list(period_schema or []) + [ - col for cols in (block_schemas or {}).values() for col in cols - ] + # Union[float, str] is used by item_class() for time_series and np.object_ columns. + # Check the period schema, all static block schemas, and all period arms. + _all_schema_cols = ( + list(period_schema or []) + + [col for cols in (block_schemas or {}).values() for col in cols] + + [col for arm in (period_arms or []) for col in arm.schema] + ) has_union = any( col.get("time_series") or col.get("dtype") == "np.object_" for col in _all_schema_cols if col.get("role") not in ("keystring_value", "boundname") and not col.get("prefix") ) # prefix= row columns (file references, e.g. LAK tables' TAB6 FILEIN) - # become Path fields via path() in row_class(), not Union[float, str]. + # become Path fields via path() in item_class(), not Union[float, str]. _row_path_cols = [col for col in _all_schema_cols if col.get("prefix")] has_row_path_cols = bool(_row_path_cols) has_optional_row_path_cols = any(col.get("optional") for col in _row_path_cols) # Row class fields with cellid=/pk=/fk=/tagged=/time_series= metadata use # field(), same as any other codegen-v2 field -- checked separately from - # has_field_call since these live inside row_class()'s rendered text, not + # has_field_call since these live inside item_class()'s rendered text, not # in the package's own top-level field_specs. _row_has_field_call = any( col.get("role") in ("cellid", "feature_id", "inline_keyword") or col.get("time_series") @@ -722,7 +771,7 @@ def _new_codegen_imports( if has_inner_classes: flopy4.append("from flopy4.mf6.record import Record") if has_period_schema: - flopy4.append("from flopy4.mf6.row import Row") + flopy4.append("from flopy4.mf6.item import Item") _spec_parts: list[str] = [] if has_field_call or _row_has_field_call: _spec_parts.append("field") @@ -785,6 +834,7 @@ def build_component_spec( period_specs: list[FieldSpec] = [] inner_class_specs: list[InnerClassSpec] = [] + _inner_class_names: set[str] = set() generatable_field_objects: list[tuple[str, FieldV3]] = [] block_schemas: dict[str, list[dict]] = {} _replace_blocks = replace_list_blocks(component.name) @@ -800,8 +850,7 @@ def build_component_spec( ) period_schema: list[dict] = [] - has_period_keystring = False - has_oc_period = False + period_arms: list[PeriodArmSpec] = [] _readarray_period_fields: list[FieldV3] = [] # READARRAY period fields (CHDG, DRNG …) _standard_period_list: FieldV3 | None = None # standard (non-keystring) period List field @@ -813,30 +862,8 @@ def build_component_spec( if block_name == "period" and filters.is_list_field(f): union = filters.find_keystring_union(f) - if union is not None and _is_oc_style_union(union): - has_oc_period = True - extra_specs.extend(_expand_oc_record_field(f)) - elif union is not None: - has_period_keystring = True - # Reproduces the current runtime-compatible shape: a generic - # (index, keyword, value) approximation. Union.arms carries - # real per-arm fk/type info now, but structure.py/unstructure.py - # only understand the flat Column/Schema role vocabulary today - # (see namefile-load-plan.md, Phase 0.6a+0.6b course - # correction, 2026-08-18) -- a faithful typed-union - # representation is a follow-up once Phase 0.6's Row - # migration lands, not this pass. - period_schema = [] - if _keystring_has_index(f, union): - period_schema.append( - {"name": "number", "dfn_type": "integer", "role": "feature_id"} - ) - period_schema.extend( - [ - {"name": "keyword", "dfn_type": "string", "role": "keystring"}, - {"name": "value", "dfn_type": "object", "role": "keystring_value"}, - ] - ) + if union is not None: + period_arms = _build_period_arm_specs(f, union, component.name, _inner_class_names) else: _standard_period_list = f continue @@ -852,11 +879,10 @@ def build_component_spec( # but still needs the same per-period, fill-forward dict[int, ...] # treatment as any other period field (the whole point of a period # block is that its contents can differ/repeat across BEGIN PERIOD - # blocks). Same runtime-compatible single-column keystring shape as - # the LAK-style case above, just with exactly one column since there's - # nothing else in the block to key against. + # blocks). Same single-column keystring shape as the LAK-style case + # above, just with exactly one column since there's nothing else in + # the block to key against. if block_name == "period" and filters.is_scalar(f): - has_period_keystring = True period_schema = [{"name": f.name, "dfn_type": "keyword", "role": "keystring"}] continue @@ -868,15 +894,16 @@ def build_component_spec( target = data_specs if filters.can_generate_record_class(f): - record_spec = _build_inner_class_spec(f, component.name) - inner_class_specs.append(record_spec) + record_specs = _build_record_class_specs(f, component.name, _inner_class_names) + inner_class_specs.extend(record_specs) + outer_spec = record_specs[-1] clean_name = filters.safe_name("_".join(_strip_record_words(f.name))) inner_spec_call = _ml_field(metadata={"block": block_name}) target.append( FieldSpec( dfn_name=f.name, py_name=clean_name, - type_annotation=f"Optional[{record_spec.class_name}]", + type_annotation=f"Optional[{outer_spec.class_name}]", spec_call=inner_spec_call, generatable=True, ) @@ -904,8 +931,8 @@ def build_component_spec( for entry in replace_list_fields(component.name): has_injected_paths = True block = entry["block"] - inout = entry["inout"] - _path_meta: dict = {"block": block, "optional": True, "inout": inout} + direction = entry["direction"] + _path_meta: dict = {"block": block, "optional": True, "direction": direction} spec_call_str = _ml_field(metadata=_path_meta, converter="_optional_path", fn="path") extra_specs.append( FieldSpec( @@ -917,8 +944,8 @@ def build_component_spec( ) ) - # BlockPropertySpec-driven fields: one Optional[list[RowClass]] per block. - # The Row class's own fields are the schema -- see row_class() -- no + # BlockPropertySpec-driven fields: one Optional[list[ItemClass]] per block. + # The Item class's own fields are the schema -- see item_class() -- no # separate __*_schema__ ClassVar needed. _always_emit_set = set(always_emit_blocks(component.name)) for bp in block_properties: @@ -933,25 +960,43 @@ def build_component_spec( _meta["auto_from"] = bp.block_name if bp.block_name in _always_emit_set: _meta["always_emit"] = True - _row_cls_name = bp.block_name.capitalize() + "Row" + _item_cls_name = pascal_name(bp.block_name) extra_specs.append( FieldSpec( dfn_name=bp.block_name, py_name=bp.block_name, - type_annotation=f"Optional[list[{_row_cls_name}]]", + type_annotation=f"Optional[list[{_item_cls_name}]]", spec_call=_ml_field(metadata=_meta), generatable=True, ) ) - # Consolidate period fields into one stress_period_data field. - if period_schema: + # Consolidate period fields into one stress_period_data field. A + # keystring union's arms are real, separately-typed classes (period_arms) + # dispatched by keyword at parse time; a standard/scalar period list has + # one uniform row shape (period_schema), same as any static list block. + if period_arms: + # A per-package _StressPeriodDataItem alias (see the template) keeps + # this annotation short and readable even for LAK-sized unions + # (13 arms) -- one line per arm class name would blow past the + # line-length limit. + _spd_meta = {"block": "period", "fill_forward": True} + period_specs.append( + FieldSpec( + dfn_name="_stress_period_data", + py_name="_stress_period_data", + type_annotation="Optional[dict[int, list[_StressPeriodDataItem]]]", + spec_call=_ml_field(alias="stress_period_data", repr_=False, metadata=_spd_meta), + generatable=True, + ) + ) + elif period_schema: _spd_meta = {"block": "period", "fill_forward": True} period_specs.append( FieldSpec( dfn_name="_stress_period_data", py_name="_stress_period_data", - type_annotation="Optional[dict[int, list[Row]]]", + type_annotation="Optional[dict[int, list[StressPeriodData]]]", spec_call=_ml_field(alias="stress_period_data", repr_=False, metadata=_spd_meta), generatable=True, ) @@ -1013,7 +1058,7 @@ def build_component_spec( multi=multi, slntype=slntype is not None, has_inner_classes=has_inner_classes, - has_period_schema=bool(period_schema) or bool(block_schemas), + has_period_schema=bool(period_schema) or bool(block_schemas) or bool(period_arms), has_path=( any(filters.is_file_record(f) for _, f in generatable_field_objects) or has_injected_paths @@ -1025,6 +1070,7 @@ def build_component_spec( has_path_call=_has_path_call, has_readarray_period=bool(_readarray_period_fields), period_schema=period_schema, + period_arms=period_arms, block_schemas=block_schemas, ) @@ -1040,9 +1086,9 @@ def build_component_spec( outpath=filters.output_path(component.name, root), block_properties=block_properties, period_schema=period_schema, + period_arms=period_arms, block_schemas=block_schemas, has_maxbound=has_maxbound, - has_keystring_period=has_period_keystring or has_oc_period, has_griddata=_has_griddata, has_readarray_period=bool(_readarray_period_fields), ) @@ -1063,7 +1109,8 @@ def _get_env() -> jinja2.Environment: undefined=jinja2.StrictUndefined, ) env.filters["python_repr"] = python_repr - env.filters["row_class"] = row_class + env.filters["item_class"] = item_class + env.filters["pascal_name"] = pascal_name return env diff --git a/flopy4/mf6/utils/codegen/overrides.py b/flopy4/mf6/utils/codegen/overrides.py index 0c96595d..bad9d975 100644 --- a/flopy4/mf6/utils/codegen/overrides.py +++ b/flopy4/mf6/utils/codegen/overrides.py @@ -63,25 +63,13 @@ def apply(dfn_name: str, f: FieldT) -> FieldT: return f.model_copy(update=patches) -def extra_list_blocks(dfn_name: str) -> list[dict]: - """Return extra list block definitions for a DFN missing from v2 TOML conversion. - - Used for list blocks dropped by dfn2toml (e.g. SSM's sources recarray, which - has no dimensions block and is not captured in v2 TOML). Each dict has keys: - ``block``, ``dim``, and ``columns`` (list of column dicts with name/type/longname). - """ - return list( - _OVERRIDES.get("_package_extras", {}).get(dfn_name, {}).get("extra_list_blocks", []) - ) - - def replace_list_fields(dfn_name: str) -> list[dict]: """Return path field definitions that replace a list block in a DFN. Used when a packagedata block has heterogeneous rows (e.g. prt-fmi's GWFHEAD/GWFBUDGET/GWFSPDIS rows) that are more naturally represented as individual Optional[Path] fields than as columnar arrays. Each dict has - keys: ``block``, ``name``, ``inout``, and ``longname``. + keys: ``block``, ``name``, ``direction``, and ``longname``. """ return list( _OVERRIDES.get("_package_extras", {}).get(dfn_name, {}).get("replace_list_fields", []) @@ -93,20 +81,6 @@ def replace_list_blocks(dfn_name: str) -> set[str]: return {entry["block"] for entry in replace_list_fields(dfn_name)} -def extra_period_fields(dfn_name: str) -> list[dict]: - """Return embedded-keystring period field definitions for a DFN. - - Used for advanced packages (LAK, MAW, SFR) whose period block uses - ``feature_num KEYWORD value`` rows rather than columnar arrays. Each - dict requires ``keyword`` and ``feature_dim``; ``prefix`` is optional - (py_name is derived as ``{prefix}_{keyword.lower()}`` when set, else - ``keyword.lower()``); ``dtype`` defaults to ``"double precision"``. - """ - return list( - _OVERRIDES.get("_package_extras", {}).get(dfn_name, {}).get("extra_period_fields", []) - ) - - def extra_record_children(dfn_name: str, field_name: str) -> list[dict]: """Return extra child dicts to inject into an inner-class record. diff --git a/flopy4/mf6/utils/codegen/templates/package.py.jinja b/flopy4/mf6/utils/codegen/templates/package.py.jinja index bc329ab4..4b269fb8 100644 --- a/flopy4/mf6/utils/codegen/templates/package.py.jinja +++ b/flopy4/mf6/utils/codegen/templates/package.py.jinja @@ -12,16 +12,6 @@ {% for line in spec.imports.flopy4 %} {{ line }} {% endfor %} -{% if spec.period_schema %} -{# The period Row class is always literally named "Row" (see row_class() in - filters.py) -- `class Row(Row):` doesn't resolve for mypy (the name gets - shadowed by the class being defined before the base expression is - "seen"), even though Python itself resolves it fine at runtime. This - alias gives that one class a distinct base name; other (block-schema) - row classes don't collide, so they subclass plain `Row` directly. #} - -_Row = Row -{% endif %} @attrs.define(kw_only=True, slots=False) @@ -44,7 +34,11 @@ class {{ spec.class_name }}({{ spec.base_class }}): _extra_tokens: ClassVar[tuple[str, ...]] = {{ rec.extra_tokens_repr }} {% endif %} {% for f in rec.fields %} -{% if f.tagged and f.optional %} +{% if f.nested and f.optional %} + {{ f.py_name }}: "Optional[{{ spec.class_name }}.{{ f.type_annotation }}]" = attrs.field(default=None) +{% elif f.nested %} + {{ f.py_name }}: "{{ spec.class_name }}.{{ f.type_annotation }}" = attrs.field() +{% elif f.tagged and f.optional %} {{ f.py_name }}: {{ f.type_annotation }} = attrs.field(default=None, metadata={"tagged": True}) {% elif f.tagged %} {{ f.py_name }}: {{ f.type_annotation }} = attrs.field(metadata={"tagged": True}) @@ -57,11 +51,23 @@ class {{ spec.class_name }}({{ spec.base_class }}): {% endfor %} {% for block_name, schema in spec.block_schemas.items() %} -{{ schema | row_class(block_name | capitalize + "Row", False, block_name == "packagedata") }} +{{ schema | item_class(block_name | pascal_name, False, block_name == "packagedata") }} {% endfor %} {% if spec.period_schema %} -{{ spec.period_schema | row_class("Row", True) }} +{{ spec.period_schema | item_class("StressPeriodData", True) }} + +{% endif %} +{% for arm in spec.period_arms %} +{{ arm.schema | item_class(arm.class_name, False, False, arm.keyword) }} + +{% endfor %} +{% if spec.period_arms %} + _StressPeriodDataItem = ( +{% for arm in spec.period_arms %} + {{ arm.class_name }}{{ " |" if not loop.last }} +{% endfor %} + ) {% endif %} {% for f in spec.fields %} @@ -71,12 +77,15 @@ class {{ spec.class_name }}({{ spec.base_class }}): # TODO: {{ f.dfn_name }} — {{ f.skip_reason }} {% endif %} {% endfor %} -{% if spec.period_schema or spec.block_schemas %} +{% if spec.period_schema or spec.block_schemas or spec.period_arms %} {% if spec.period_schema %} -{{ spec.class_name }}Row = {{ spec.class_name }}.Row +{{ spec.class_name }}StressPeriodData = {{ spec.class_name }}.StressPeriodData {% endif %} {% for block_name in spec.block_schemas %} -{{ spec.class_name }}{{ block_name | capitalize }}Row = {{ spec.class_name }}.{{ block_name | capitalize }}Row +{{ spec.class_name }}{{ block_name | pascal_name }} = {{ spec.class_name }}.{{ block_name | pascal_name }} +{% endfor %} +{% for arm in spec.period_arms %} +{{ spec.class_name }}{{ arm.class_name }} = {{ spec.class_name }}.{{ arm.class_name }} {% endfor %} {% endif %} diff --git a/flopy4/mf6/utl/ats.py b/flopy4/mf6/utl/ats.py index 9b557438..852f581e 100644 --- a/flopy4/mf6/utl/ats.py +++ b/flopy4/mf6/utl/ats.py @@ -3,8 +3,8 @@ import attrs +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field @@ -13,8 +13,8 @@ class Ats(Package): dfn_name: ClassVar[str] = "utl-ats" @attrs.define - class PerioddataRow(Row): - iperats: int = field(pk=True) + class Perioddata(Item): + iperats: int = field(index=True, pk=True) dt0: float = field() dtmin: float = field() dtmax: float = field() @@ -25,11 +25,11 @@ class PerioddataRow(Row): default=1, block="dimensions", ) - perioddata: Optional[list[PerioddataRow]] = field( + perioddata: Optional[list[Perioddata]] = field( default=None, block="perioddata", auto_from="perioddata", ) -AtsPerioddataRow = Ats.PerioddataRow +AtsPerioddata = Ats.Perioddata diff --git a/flopy4/mf6/utl/hpc.py b/flopy4/mf6/utl/hpc.py index 67d43647..c671f900 100644 --- a/flopy4/mf6/utl/hpc.py +++ b/flopy4/mf6/utl/hpc.py @@ -1,29 +1,31 @@ # autogenerated file, do not modify -from typing import Optional, Union +from typing import ClassVar, Optional, Union import attrs +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field @attrs.define(kw_only=True, slots=False) class Hpc(Package): + dfn_name: ClassVar[str] = "utl-hpc" + @attrs.define - class PartitionsRow(Row): - mname: Union[float, str] - mrank: int + class Partitions(Item): + mname: Union[float, str] = field() + mrank: int = field() print_table: bool = field( default=False, block="options", optional=True, ) - partitions: Optional[list[PartitionsRow]] = field( + partitions: Optional[list[Partitions]] = field( default=None, block="partitions", ) -HpcPartitionsRow = Hpc.PartitionsRow +HpcPartitions = Hpc.Partitions diff --git a/flopy4/mf6/utl/laktab.py b/flopy4/mf6/utl/laktab.py index 32d680d6..bdd08a5b 100644 --- a/flopy4/mf6/utl/laktab.py +++ b/flopy4/mf6/utl/laktab.py @@ -3,8 +3,8 @@ import attrs +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field @@ -15,10 +15,10 @@ class Laktab(Package): multi_package: ClassVar[bool] = True @attrs.define - class TableRow(Row): - stage: float - volume: float - sarea: float + class Table(Item): + stage: float = field() + volume: float = field() + sarea: float = field() barea: Optional[float] = field(default=None, optional=True) nrow: Optional[int] = field( @@ -29,11 +29,11 @@ class TableRow(Row): default=None, block="dimensions", ) - table: Optional[list[TableRow]] = field( + table: Optional[list[Table]] = field( default=None, block="table", auto_from="table", ) -LaktabTableRow = Laktab.TableRow +LaktabTable = Laktab.Table diff --git a/flopy4/mf6/utl/sfrtab.py b/flopy4/mf6/utl/sfrtab.py index f632d243..808f3483 100644 --- a/flopy4/mf6/utl/sfrtab.py +++ b/flopy4/mf6/utl/sfrtab.py @@ -3,8 +3,8 @@ import attrs +from flopy4.mf6.item import Item from flopy4.mf6.package import Package -from flopy4.mf6.row import Row from flopy4.mf6.spec import field @@ -15,9 +15,9 @@ class Sfrtab(Package): multi_package: ClassVar[bool] = True @attrs.define - class TableRow(Row): - xfraction: float - height: float + class Table(Item): + xfraction: float = field() + height: float = field() manfraction: Optional[float] = field(default=None, optional=True) nrow: Optional[int] = field( @@ -28,11 +28,11 @@ class TableRow(Row): default=None, block="dimensions", ) - table: Optional[list[TableRow]] = field( + table: Optional[list[Table]] = field( default=None, block="table", auto_from="table", ) -SfrtabTableRow = Sfrtab.TableRow +SfrtabTable = Sfrtab.Table diff --git a/flopy4/mf6/utl/spca.py b/flopy4/mf6/utl/spca.py index 8a6421d6..dbb8a534 100644 --- a/flopy4/mf6/utl/spca.py +++ b/flopy4/mf6/utl/spca.py @@ -29,7 +29,7 @@ class Spca(Package): converter=_optional_path, block="options", optional=True, - inout="filein", + direction="in", ) concentration: Optional[FloatArrayLike] = field( default=None, diff --git a/pixi.lock b/pixi.lock index 02f0ad84..ef989724 100644 --- a/pixi.lock +++ b/pixi.lock @@ -105,7 +105,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -255,7 +255,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h7142dee_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -396,7 +396,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl @@ -542,7 +542,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -706,7 +706,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl @@ -985,7 +985,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h7142dee_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl @@ -1253,7 +1253,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl @@ -1526,7 +1526,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl @@ -1817,7 +1817,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -2086,7 +2086,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h7142dee_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -2347,7 +2347,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -2612,7 +2612,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -2895,7 +2895,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -3141,7 +3141,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h7142dee_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -3379,7 +3379,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -3622,7 +3622,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -3882,7 +3882,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-8_cp312.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -4125,7 +4125,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h7142dee_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -4360,7 +4360,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -4600,7 +4600,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -4856,7 +4856,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -5101,7 +5101,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h7142dee_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -5338,7 +5338,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl @@ -5580,7 +5580,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 - pypi: git+https://github.com/wpbonelli/xattree.git#82942ffb8237d56c97451765ac3e2b16d6cb0d2c - pypi: https://files.pythonhosted.org/packages/00/01/4001033457f25ecdc2b1ffd513ca0b76200b9ea009dd64f6c1aad2dde133/boltons-26.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -10507,7 +10507,7 @@ packages: - build ; extra == 'build' - twine ; extra == 'build' requires_python: '>=3.11,<3.14' -- pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#29f7e0f6afe44f7566296dd87ec3951952935589 +- pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#a9d66f586b55f9756167cbea1251d00eeac168b4 name: modflow-devtools version: 1.10.0.dev1 requires_dist: diff --git a/test/mf6/test_mf6_adapters.py b/test/mf6/test_mf6_adapters.py index e962a713..a926a90d 100644 --- a/test/mf6/test_mf6_adapters.py +++ b/test/mf6/test_mf6_adapters.py @@ -36,8 +36,7 @@ def quickstart_model(): parent=gwf, budget_file=f"{name}.bud", head_file=f"{name}.hds", - save_head={0: "all"}, - save_budget={0: "all"}, + stress_period_data={0: [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", "ALL")]}, ) return gwf @@ -75,7 +74,7 @@ def test_flopy3_model(tmp_path): ims.inner_hclose = 1e-6 ims.inner_rclose = 0.1000000 ic = Ic(dims=dims) - oc = Oc(dims=dims, save_head={0: "all"}, save_budget={0: "all"}) + oc = Oc(dims=dims, stress_period_data={0: [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", "ALL")]}) npf = Npf(dims=dims) chd = Chd(dims=dims, stress_period_data={0: [[(0, 0, 0), 1.0], [(0, 9, 9), 0.0]]}) diff --git a/test/mf6/test_mf6_codec.py b/test/mf6/test_mf6_codec.py index 41540496..c8f78177 100644 --- a/test/mf6/test_mf6_codec.py +++ b/test/mf6/test_mf6_codec.py @@ -90,19 +90,23 @@ def test_dumps_oc(): dims={"nper": 1}, budget_file="test.bud", head_file="test.hds", - save_head={0: "all"}, - save_budget={0: "all"}, - print_head={0: "all"}, - print_budget={0: "all"}, + stress_period_data={ + 0: [ + ("SAVE", "HEAD", "ALL"), + ("SAVE", "BUDGET", "ALL"), + ("PRINT", "HEAD", "ALL"), + ("PRINT", "BUDGET", "ALL"), + ] + }, ) dumped = dumps(COMPONENT_CONVERTER.unstructure(oc)) print("OC dump:") print(dumped) - assert "SAVE HEAD all" in dumped - assert "SAVE BUDGET all" in dumped - assert "PRINT HEAD all" in dumped - assert "PRINT BUDGET all" in dumped + assert "SAVE HEAD ALL" in dumped + assert "SAVE BUDGET ALL" in dumped + assert "PRINT HEAD ALL" in dumped + assert "PRINT BUDGET ALL" in dumped assert dumped loaded = loads(dumped) @@ -117,17 +121,21 @@ def test_dumps_oc2(): dims={"nper": 1}, budget_file="test.bud", head_file="test.hds", - save_head={0: "last"}, - save_budget={0: "first"}, - print_head={0: "first"}, + stress_period_data={ + 0: [ + ("SAVE", "HEAD", "LAST"), + ("SAVE", "BUDGET", "FIRST"), + ("PRINT", "HEAD", "FIRST"), + ] + }, ) dumped = dumps(COMPONENT_CONVERTER.unstructure(oc)) print("OC dump:") print(dumped) - assert "SAVE HEAD last" in dumped - assert "SAVE BUDGET first" in dumped - assert "PRINT HEAD first" in dumped + assert "SAVE HEAD LAST" in dumped + assert "SAVE BUDGET FIRST" in dumped + assert "PRINT HEAD FIRST" in dumped assert dumped loaded = loads(dumped) @@ -756,21 +764,23 @@ def test_dumps_zero_field_exg(): def test_dumps_gwt_oc_per_period(): - """gwt-oc save/print fields write SAVE CONCENTRATION and SAVE BUDGET per period.""" + """gwt-oc stress_period_data writes SAVE CONCENTRATION and SAVE BUDGET per period.""" from flopy4.mf6.gwt.oc import Oc oc = Oc( dims={"nper": 2}, budget_file="gwt.bud", concentration_file="gwt.conc", - save_concentration={0: "last", 1: "all"}, - save_budget={0: "last"}, + stress_period_data={ + 0: [("SAVE", "CONCENTRATION", "LAST"), ("SAVE", "BUDGET", "LAST")], + 1: [("SAVE", "CONCENTRATION", "ALL")], + }, ) dumped = dumps(COMPONENT_CONVERTER.unstructure(oc)) - assert "SAVE CONCENTRATION last" in dumped - assert "SAVE CONCENTRATION all" in dumped - assert "SAVE BUDGET last" in dumped + assert "SAVE CONCENTRATION LAST" in dumped + assert "SAVE CONCENTRATION ALL" in dumped + assert "SAVE BUDGET LAST" in dumped def test_dumps_gwt_oc_wildcard(): @@ -780,13 +790,12 @@ def test_dumps_gwt_oc_wildcard(): oc = Oc( budget_file="gwt.bud", concentration_file="gwt.conc", - save_concentration={"*": "last"}, - save_budget={"*": "all"}, + stress_period_data={"*": [("SAVE", "CONCENTRATION", "LAST"), ("SAVE", "BUDGET", "ALL")]}, ) dumped = dumps(COMPONENT_CONVERTER.unstructure(oc)) - assert "SAVE CONCENTRATION last" in dumped - assert "SAVE BUDGET all" in dumped + assert "SAVE CONCENTRATION LAST" in dumped + assert "SAVE BUDGET ALL" in dumped def test_dumps_prt_prp_release_setting(): @@ -795,9 +804,8 @@ def test_dumps_prt_prp_release_setting(): PRP's `releasesetting` keystring union has no per-row index (unlike LAK/ LKE/SFR) -- the v1 DFN declares it a bare `recarray releasesetting` with no feature-id column, and dev3 confirms no arm carries a pk/fk field, so - rows are plain (keyword, value) pairs (see make.py's - _keystring_has_index). "frequency" carries an Integer payload; "first" is - a bare keyword with no payload (value=None). + rows are typed per-arm classes: `Frequency` carries an Integer payload; + `First` is a bare keyword with no payload at all. """ from flopy4.mf6.prt.prp import Prp @@ -805,7 +813,7 @@ def test_dumps_prt_prp_release_setting(): dims={"nper": 2}, stress_period_data={ 0: [("FREQUENCY", 2)], - 1: [("FIRST", None)], + 1: [("FIRST",)], }, ) @@ -824,7 +832,7 @@ def test_prt_prp_period_roundtrip(): dims={"nper": 2}, stress_period_data={ 0: [("FREQUENCY", 2)], - 1: [("FIRST", None)], + 1: [("FIRST",)], }, ) text = dumps(unstructure_component(prp)) @@ -837,12 +845,12 @@ def test_prt_prp_period_roundtrip(): p0 = spd[0] assert len(p0) == 1 - assert p0[0].keyword == "FREQUENCY" - assert float(p0[0].value) == pytest.approx(2) + assert isinstance(p0[0], Prp.Frequency) + assert p0[0].frequency == 2 p1 = spd[1] assert len(p1) == 1 - assert p1[0].keyword == "FIRST" + assert isinstance(p1[0], Prp.First) # --------------------------------------------------------------------------- @@ -867,17 +875,21 @@ def test_oc_period_string_int_keys(): from flopy4.mf6.gwf import Oc dims = {"nper": 3} - # integer keys - oc_int = Oc(dims=dims, save_budget={0: "all", 1: "last"}) - # string-int keys - oc_str = Oc(dims=dims, save_budget={"0": "all", "1": "last"}) + oc_int = Oc( + dims=dims, + stress_period_data={0: [("SAVE", "BUDGET", "ALL")], 1: [("SAVE", "BUDGET", "LAST")]}, + ) + oc_str = Oc( + dims=dims, + stress_period_data={"0": [("SAVE", "BUDGET", "ALL")], "1": [("SAVE", "BUDGET", "LAST")]}, + ) pb_int = _period_blocks(oc_int) pb_str = _period_blocks(oc_str) assert pb_int == pb_str - assert pb_int["period 1"]["save budget"] == "all" - assert pb_int["period 2"]["save budget"] == "last" + assert pb_int["period 1"]["period"] == [("SAVE", "BUDGET", "ALL")] + assert pb_int["period 2"]["period"] == [("SAVE", "BUDGET", "LAST")] # No fill-forward: period 3 was not specified so it produces no block. assert "period 3" not in pb_int @@ -887,15 +899,15 @@ def test_oc_period_wildcard_fillforward(): from flopy4.mf6.gwf import Oc oc = Oc( - save_head={0: "all", 1: "all", 2: "all", 3: "all"}, - save_budget={0: "last", 1: "last", 2: "last", 3: "last"}, + stress_period_data={ + i: [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", "LAST")] for i in range(4) + }, ) pb = _period_blocks(oc) assert len(pb) == 4 for i in range(1, 5): - assert pb[f"period {i}"]["save head"] == "all" - assert pb[f"period {i}"]["save budget"] == "last" + assert pb[f"period {i}"]["period"] == [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", "LAST")] def test_oc_period_steps_syntax(): @@ -904,35 +916,43 @@ def test_oc_period_steps_syntax(): oc = Oc( dims={"nper": 2}, - save_budget={0: "STEPS 1 3 5"}, - print_budget={0: "STEPS 1", 1: "last"}, + stress_period_data={ + 0: [("SAVE", "BUDGET", "STEPS", 1, 3, 5), ("PRINT", "BUDGET", "STEPS", 1)], + 1: [("PRINT", "BUDGET", "LAST")], + }, ) pb = _period_blocks(oc) - assert pb["period 1"]["save budget"] == "STEPS 1 3 5" - assert pb["period 1"]["print budget"] == "STEPS 1" - assert "save budget" not in pb["period 2"] # no fill-forward: period 2 not specified - assert pb["period 2"]["print budget"] == "last" + assert ("SAVE", "BUDGET", "STEPS", 1, 3, 5) in pb["period 1"]["period"] + assert ("PRINT", "BUDGET", "STEPS", 1) in pb["period 1"]["period"] + # no fill-forward: period 2 doesn't re-emit SAVE BUDGET + assert not any(t[:2] == ("SAVE", "BUDGET") for t in pb["period 2"]["period"]) + assert ("PRINT", "BUDGET", "LAST") in pb["period 2"]["period"] def test_oc_period_stop_sentinel(): - """Empty string '' stop sentinel is skipped; unspecified periods produce no entry.""" + """Omitting a setting from a later period's list is how it "stops" -- + no special empty-string sentinel needed under stress_period_data, unlike + the old per-rtype dict API. Matches every other stress-period-data + package's semantics: each kper's list is exactly what gets written.""" from flopy4.mf6.gwf import Oc oc = Oc( - save_head={0: "all", 1: "all", 2: "all"}, - save_budget={0: "STEPS 1", 1: ""}, # "" stop sentinel skipped + stress_period_data={ + 0: [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", "STEPS", 1)], + 1: [("SAVE", "HEAD", "ALL")], + 2: [("SAVE", "HEAD", "ALL")], + }, ) pb = _period_blocks(oc) assert len(pb) == 3 for i in range(1, 4): - assert pb[f"period {i}"]["save head"] == "all" + assert ("SAVE", "HEAD", "ALL") in pb[f"period {i}"]["period"] - # Only period 1 has save_budget; "" sentinel and unspecified periods omit it - assert pb["period 1"]["save budget"] == "STEPS 1" - assert "save budget" not in pb["period 2"] - assert "save budget" not in pb["period 3"] + assert ("SAVE", "BUDGET", "STEPS", 1) in pb["period 1"]["period"] + assert not any(t[:2] == ("SAVE", "BUDGET") for t in pb["period 2"]["period"]) + assert not any(t[:2] == ("SAVE", "BUDGET") for t in pb["period 3"]["period"]) def test_oc_period_mixed_keys_no_silent_drop(): @@ -941,13 +961,17 @@ def test_oc_period_mixed_keys_no_silent_drop(): oc = Oc( dims={"nper": 3}, - save_head={"0": "first", 1: "last", 2: "all"}, + stress_period_data={ + "0": [("SAVE", "HEAD", "FIRST")], + 1: [("SAVE", "HEAD", "LAST")], + 2: [("SAVE", "HEAD", "ALL")], + }, ) pb = _period_blocks(oc) - assert pb["period 1"]["save head"] == "first" - assert pb["period 2"]["save head"] == "last" - assert pb["period 3"]["save head"] == "all" + assert ("SAVE", "HEAD", "FIRST") in pb["period 1"]["period"] + assert ("SAVE", "HEAD", "LAST") in pb["period 2"]["period"] + assert ("SAVE", "HEAD", "ALL") in pb["period 3"]["period"] def test_oc_dumps_steps_in_output(): @@ -957,15 +981,20 @@ def test_oc_dumps_steps_in_output(): oc = Oc( budget_file="t.bud", head_file="t.hds", - save_head={0: "all", 1: "all"}, - save_budget={0: "STEPS 1 5"}, - print_budget={0: "last", 1: "last"}, + stress_period_data={ + 0: [ + ("SAVE", "HEAD", "ALL"), + ("SAVE", "BUDGET", "STEPS", 1, 5), + ("PRINT", "BUDGET", "LAST"), + ], + 1: [("SAVE", "HEAD", "ALL"), ("PRINT", "BUDGET", "LAST")], + }, ) dumped = dumps(COMPONENT_CONVERTER.unstructure(oc)) - assert "SAVE HEAD all" in dumped + assert "SAVE HEAD ALL" in dumped assert "SAVE BUDGET STEPS 1 5" in dumped - assert "PRINT BUDGET last" in dumped + assert "PRINT BUDGET LAST" in dumped # Period 2 must not re-emit SAVE BUDGET lines = dumped.splitlines() period2_start = next(i for i, l in enumerate(lines) if "BEGIN PERIOD 2" in l) @@ -978,15 +1007,18 @@ def test_oc_period_frequency(): from flopy4.mf6.gwf import Oc oc = Oc( - save_head={0: "FREQUENCY 2", 1: "FREQUENCY 2", 2: "FREQUENCY 2"}, - save_budget={0: "all"}, + stress_period_data={ + 0: [("SAVE", "HEAD", "FREQUENCY", 2), ("SAVE", "BUDGET", "ALL")], + 1: [("SAVE", "HEAD", "FREQUENCY", 2)], + 2: [("SAVE", "HEAD", "FREQUENCY", 2)], + }, ) pb = _period_blocks(oc) - assert pb["period 1"]["save head"] == "FREQUENCY 2" - assert pb["period 2"]["save head"] == "FREQUENCY 2" - assert pb["period 3"]["save head"] == "FREQUENCY 2" - assert pb["period 1"]["save budget"] == "all" + assert ("SAVE", "HEAD", "FREQUENCY", 2) in pb["period 1"]["period"] + assert ("SAVE", "HEAD", "FREQUENCY", 2) in pb["period 2"]["period"] + assert ("SAVE", "HEAD", "FREQUENCY", 2) in pb["period 3"]["period"] + assert ("SAVE", "BUDGET", "ALL") in pb["period 1"]["period"] # --------------------------------------------------------------------------- @@ -1356,10 +1388,10 @@ def test_headprint_from_tokens_full_string(): from flopy4.mf6.gwf.oc import Oc hp = Oc.Headprint.from_tokens("HEAD PRINT_FORMAT COLUMNS 10 WIDTH 12 DIGITS 6 exponential") - assert hp.format_ == "exponential" - assert hp.columns == 10 - assert hp.width == 12 - assert hp.digits == 6 + assert hp.formatrecord.format_ == "exponential" + assert hp.formatrecord.columns == 10 + assert hp.formatrecord.width == 12 + assert hp.formatrecord.digits == 6 def test_headprint_from_tokens_no_prefix(): @@ -1367,10 +1399,10 @@ def test_headprint_from_tokens_no_prefix(): from flopy4.mf6.gwf.oc import Oc hp = Oc.Headprint.from_tokens("COLUMNS 10 WIDTH 12 DIGITS 6 exponential") - assert hp.format_ == "exponential" - assert hp.columns == 10 - assert hp.width == 12 - assert hp.digits == 6 + assert hp.formatrecord.format_ == "exponential" + assert hp.formatrecord.columns == 10 + assert hp.formatrecord.width == 12 + assert hp.formatrecord.digits == 6 def test_headprint_from_tokens_format_only(): @@ -1378,10 +1410,10 @@ def test_headprint_from_tokens_format_only(): from flopy4.mf6.gwf.oc import Oc hp = Oc.Headprint.from_tokens("exponential") - assert hp.format_ == "exponential" - assert hp.columns is None - assert hp.width is None - assert hp.digits is None + assert hp.formatrecord.format_ == "exponential" + assert hp.formatrecord.columns is None + assert hp.formatrecord.width is None + assert hp.formatrecord.digits is None def test_headprint_from_tokens_list(): @@ -1389,10 +1421,10 @@ def test_headprint_from_tokens_list(): from flopy4.mf6.gwf.oc import Oc hp = Oc.Headprint.from_tokens(["COLUMNS", "10", "exponential"]) - assert hp.format_ == "exponential" - assert hp.columns == 10 - assert hp.width is None - assert hp.digits is None + assert hp.formatrecord.format_ == "exponential" + assert hp.formatrecord.columns == 10 + assert hp.formatrecord.width is None + assert hp.formatrecord.digits is None def test_headprint_from_tokens_tagged_types(): @@ -1400,11 +1432,11 @@ def test_headprint_from_tokens_tagged_types(): from flopy4.mf6.gwf.oc import Oc hp = Oc.Headprint.from_tokens("WIDTH 15 DIGITS 4 fixed") - assert isinstance(hp.width, int) - assert hp.width == 15 - assert isinstance(hp.digits, int) - assert hp.digits == 4 - assert hp.columns is None + assert isinstance(hp.formatrecord.width, int) + assert hp.formatrecord.width == 15 + assert isinstance(hp.formatrecord.digits, int) + assert hp.formatrecord.digits == 4 + assert hp.formatrecord.columns is None def test_rclose_from_tokens_with_keyword(): @@ -1765,35 +1797,28 @@ def test_lak_keystring_period_roundtrip(): # Period 0: 4 rows p0 = spd[0] assert len(p0) == 4 - assert p0[0].number == 0 # 0-based feature id - assert p0[0].keyword == "STATUS" - assert p0[0].value == "ACTIVE" - assert p0[1].number == 0 - assert p0[1].keyword == "RAINFALL" - assert float(p0[1].value) == pytest.approx(0.1) - assert p0[2].number == 1 - assert p0[2].keyword == "STATUS" - assert p0[2].value == "CONSTANT" - assert p0[3].number == 1 - assert p0[3].keyword == "STAGE" - assert float(p0[3].value) == pytest.approx(5.0) + assert isinstance(p0[0], Lak.Status) and p0[0].lakeno == 0 # 0-based feature id + assert p0[0].status == "ACTIVE" + assert isinstance(p0[1], Lak.Rainfall) and p0[1].lakeno == 0 + assert float(p0[1].rainfall) == pytest.approx(0.1) + assert isinstance(p0[2], Lak.Status) and p0[2].lakeno == 1 + assert p0[2].status == "CONSTANT" + assert isinstance(p0[3], Lak.Stage) and p0[3].lakeno == 1 + assert float(p0[3].stage) == pytest.approx(5.0) # Period 1: 1 row p1 = spd[1] assert len(p1) == 1 - assert p1[0].number == 0 - assert p1[0].keyword == "STATUS" - assert p1[0].value == "INACTIVE" + assert isinstance(p1[0], Lak.Status) and p1[0].lakeno == 0 + assert p1[0].status == "INACTIVE" # Period 2: 2 rows p2 = spd[2] assert len(p2) == 2 - assert p2[0].number == 0 - assert p2[0].keyword == "STATUS" - assert p2[0].value == "ACTIVE" - assert p2[1].number == 1 - assert p2[1].keyword == "WITHDRAWAL" - assert float(p2[1].value) == pytest.approx(100.0) + assert isinstance(p2[0], Lak.Status) and p2[0].lakeno == 0 + assert p2[0].status == "ACTIVE" + assert isinstance(p2[1], Lak.Withdrawal) and p2[1].lakeno == 1 + assert float(p2[1].withdrawal) == pytest.approx(100.0) # --------------------------------------------------------------------------- @@ -1988,21 +2013,17 @@ def test_lkt_period_roundtrip(): p0 = spd[0] assert len(p0) == 3 - assert p0[0].number == 0 - assert p0[0].keyword == "STATUS" - assert p0[0].value == "ACTIVE" - assert p0[1].number == 1 - assert p0[1].keyword == "STATUS" - assert p0[1].value == "CONSTANT" - assert p0[2].number == 0 - assert p0[2].keyword == "CONCENTRATION" - assert float(p0[2].value) == pytest.approx(10.0) + assert isinstance(p0[0], Lkt.Status) and p0[0].ifno == 0 + assert p0[0].status == "ACTIVE" + assert isinstance(p0[1], Lkt.Status) and p0[1].ifno == 1 + assert p0[1].status == "CONSTANT" + assert isinstance(p0[2], Lkt.Concentration) and p0[2].ifno == 0 + assert float(p0[2].concentration) == pytest.approx(10.0) p1 = spd[1] assert len(p1) == 1 - assert p1[0].number == 0 - assert p1[0].keyword == "STATUS" - assert p1[0].value == "INACTIVE" + assert isinstance(p1[0], Lkt.Status) and p1[0].ifno == 0 + assert p1[0].status == "INACTIVE" def test_lkt_packagedata_roundtrip(): @@ -2103,21 +2124,17 @@ def test_lke_period_roundtrip(): p0 = spd[0] assert len(p0) == 3 - assert p0[0].number == 0 - assert p0[0].keyword == "STATUS" - assert p0[0].value == "ACTIVE" - assert p0[1].number == 1 - assert p0[1].keyword == "STATUS" - assert p0[1].value == "CONSTANT" - assert p0[2].number == 0 - assert p0[2].keyword == "TEMPERATURE" - assert float(p0[2].value) == pytest.approx(18.5) + assert isinstance(p0[0], Lke.Status) and p0[0].lakeno == 0 + assert p0[0].status == "ACTIVE" + assert isinstance(p0[1], Lke.Status) and p0[1].lakeno == 1 + assert p0[1].status == "CONSTANT" + assert isinstance(p0[2], Lke.Temperature) and p0[2].lakeno == 0 + assert float(p0[2].temperature) == pytest.approx(18.5) p1 = spd[1] assert len(p1) == 1 - assert p1[0].number == 0 - assert p1[0].keyword == "STATUS" - assert p1[0].value == "INACTIVE" + assert isinstance(p1[0], Lke.Status) and p1[0].lakeno == 0 + assert p1[0].status == "INACTIVE" def test_lke_packagedata_roundtrip(): diff --git a/test/mf6/test_mf6_codegen.py b/test/mf6/test_mf6_codegen.py index 55b154c5..038008b7 100644 --- a/test/mf6/test_mf6_codegen.py +++ b/test/mf6/test_mf6_codegen.py @@ -26,11 +26,11 @@ can_expand_record, class_name, is_generatable, + item_class, model_abbr, module_name, output_path, py_type, - row_class, safe_name, ) from flopy4.mf6.utils.codegen.make import build_component_spec, make_modules @@ -87,7 +87,9 @@ def all_dfns(dfn_path): "gwt-ist": ("Ist", "Package", "gwt"), } -# Tier 1a: OC record expansion — saverecord/printrecord → per-rtype NDArray[np.str_] fields. +# Tier 1a: OC period keystring union — saverecord/printrecord arms become +# real typed Save/Print classes composed into stress_period_data, the same +# generic mechanism LAK/SFR/MAW use for their own period keystring settings. # Each tuple is (class_name, base_class, model_prefix). OC_TIER = { "gwt-oc": ("Oc", "Package", "gwt"), @@ -255,57 +257,63 @@ def test_is_generatable_file_record(self): ) assert is_generatable(f) - def test_row_class_empty_returns_empty_string(self): - assert row_class([], "Row") == "" + def test_item_class_empty_returns_empty_string(self): + assert item_class([], "StressPeriodData") == "" - def test_row_class_static_block_no_aux(self): - # Static block Row (is_period=False default): no aux field. Real - # field() metadata (pk=/etc.) replaces the old Schema/Column lookup -- - # the Row class itself is the schema. + def test_item_class_static_block_no_aux(self): + # Static block item (is_period=False default): no aux field. Real + # field() metadata (pk=/etc.) is the schema. schema = [ - {"name": "ifno", "role": "feature_id", "dfn_type": "integer"}, + {"name": "ifno", "role": "feature_id", "dfn_type": "integer", "pk": True}, {"name": "strt", "role": "value", "dfn_type": "double"}, {"name": "boundname", "role": "boundname", "dfn_type": "string"}, ] - result = row_class(schema, "PackagedataRow") + result = item_class(schema, "Packagedata") assert "@attrs.define" in result - assert "class PackagedataRow(Row):" in result - assert "ifno: int = field(pk=True)" in result + assert "class Packagedata(Item):" in result + assert "ifno: int = field(index=True, pk=True)" in result assert "strt: float" in result assert "boundname: Optional[str] = field(default=None, optional=True)" in result assert "aux" not in result - def test_row_class_feature_id_with_fk_uses_fk_metadata(self): - # A feature_id column with a real fk target emits fk=, not pk=. + def test_item_class_feature_id_with_fk_uses_fk_metadata(self): + # A feature_id column always carries index= (it needs MF6's 0-based/ + # 1-based conversion); one with a real fk target also carries fk=, + # not pk= (pk and fk are mutually exclusive relational roles). schema = [ - {"name": "ifno", "role": "feature_id", "dfn_type": "integer", "fk": "packagedata.ifno"}, - {"name": "iconn", "role": "feature_id", "dfn_type": "integer"}, + { + "name": "ifno", + "role": "feature_id", + "dfn_type": "integer", + "fk": "packagedata.ifno", + }, + {"name": "iconn", "role": "feature_id", "dfn_type": "integer", "pk": True}, ] - result = row_class(schema, "ConnectiondataRow") - assert 'ifno: int = field(fk="packagedata.ifno")' in result - assert "iconn: int = field(pk=True)" in result + result = item_class(schema, "Connectiondata") + assert 'ifno: int = field(index=True, fk="packagedata.ifno")' in result + assert "iconn: int = field(index=True, pk=True)" in result - def test_row_class_period_has_aux_for_standard_stress(self): - # Period Row (is_period=True) with no keystring: aux field present. + def test_item_class_period_has_aux_for_standard_stress(self): + # Period item (is_period=True) with no keystring: aux field present. schema = [ {"name": "cellid", "role": "cellid", "dfn_type": "integer"}, {"name": "head", "role": "value", "dfn_type": "double"}, {"name": "boundname", "role": "boundname", "dfn_type": "string"}, ] - result = row_class(schema, "Row", is_period=True) + result = item_class(schema, "StressPeriodData", is_period=True) assert "aux: tuple = ()" in result - def test_row_class_period_keystring_no_aux(self): - # Period Row with keystring role: no aux even with is_period=True. + def test_item_class_period_keystring_no_aux(self): + # Period item with keystring role: no aux even with is_period=True. schema = [ {"name": "number", "role": "feature_id", "dfn_type": "integer"}, {"name": "keyword", "role": "keystring", "dfn_type": "string"}, {"name": "value", "role": "keystring_value", "dfn_type": "object"}, ] - result = row_class(schema, "Row", is_period=True) + result = item_class(schema, "StressPeriodData", is_period=True) assert "aux" not in result - def test_row_class_field_order_matches_schema(self): + def test_item_class_field_order_matches_schema(self): # Required fields declared in schema order, then optional. schema = [ {"name": "ifno", "role": "feature_id", "dfn_type": "integer"}, @@ -313,31 +321,31 @@ def test_row_class_field_order_matches_schema(self): {"name": "nlakeconn", "role": "value", "dfn_type": "integer"}, {"name": "boundname", "role": "boundname", "dfn_type": "string"}, ] - result = row_class(schema, "PackagedataRow") + result = item_class(schema, "Packagedata") lines = [ln.strip() for ln in result.splitlines() if ":" in ln and "class" not in ln] names = [ln.split(":")[0] for ln in lines] assert names == ["ifno", "strt", "nlakeconn", "boundname"] - def test_row_class_inline_keyword_optional(self): + def test_item_class_inline_keyword_optional(self): # inline_keyword role -> Optional[str], tagged=True (same convention # record.py's Record uses for optional keyword tokens). schema = [ {"name": "pname", "role": "value", "dfn_type": "string", "dtype": "np.object_"}, {"name": "mixed", "role": "inline_keyword", "dfn_type": "keyword", "optional": True}, ] - result = row_class(schema, "FileinputRow") + result = item_class(schema, "Fileinput") assert "mixed: Optional[str] = field(default=None, tagged=True, optional=True)" in result - def test_row_class_cellid_metadata(self): + def test_item_class_cellid_metadata(self): schema = [{"name": "cellid", "role": "cellid", "dfn_type": "integer"}] - result = row_class(schema, "Row", is_period=True) + result = item_class(schema, "StressPeriodData", is_period=True) assert "cellid: tuple = field(cellid=True)" in result - def test_row_class_time_series_metadata(self): + def test_item_class_time_series_metadata(self): schema = [ {"name": "head", "role": "value", "dfn_type": "double", "time_series": True}, ] - result = row_class(schema, "Row", is_period=True) + result = item_class(schema, "StressPeriodData", is_period=True) assert "head: Union[float, str] = field(time_series=True)" in result @@ -465,7 +473,7 @@ def test_mvr_list_fields_expanded_and_optional(all_dfns): assert spec.period_schema, "MVR should have a period_schema" assert "_stress_period_data" in field_map spd_field = field_map["_stress_period_data"] - assert spd_field.type_annotation == "Optional[dict[int, list[Row]]]" + assert spd_field.type_annotation == "Optional[dict[int, list[StressPeriodData]]]" # Packages block → single recarray field assert "packages" in field_map or "packages" in spec.block_schemas @@ -773,9 +781,10 @@ def test_oc_tier_generates_importable_files(tmp_path, all_dfns): assert spec.outpath == tmp_path / subdir / f"{expected_class.lower()}.py" cls = _load_class_from_spec(spec, f"_codegen_test_oc.{dfn_name}", expected_class) assert issubclass(cls, Package) - # Verify at least one OC period field was generated - oc_fields = [f for f in spec.fields if f.py_name.startswith(("save_", "print_"))] - assert oc_fields, f"{dfn_name} should have save_/print_ period fields" + # Verify the OC period arms (Save/Print, real typed classes) were generated + assert spec.period_arms, f"{dfn_name} should have period_arms (Save/Print)" + arm_names = {arm.class_name for arm in spec.period_arms} + assert {"Save", "Print"} <= arm_names, f"{dfn_name} should have Save/Print arm classes" def test_utl_tier_generates_importable_files(tmp_path, all_dfns): diff --git a/test/mf6/test_mf6_integration.py b/test/mf6/test_mf6_integration.py index 220f1819..ebe1edb1 100644 --- a/test/mf6/test_mf6_integration.py +++ b/test/mf6/test_mf6_integration.py @@ -103,10 +103,14 @@ def test_gwf_chd01(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head=["last"], - save_budget=["last"], - print_head=["last"], - print_budget=["last"], + stress_period_data={ + 0: [ + ("SAVE", "HEAD", "LAST"), + ("SAVE", "BUDGET", "LAST"), + ("PRINT", "HEAD", "LAST"), + ("PRINT", "BUDGET", "LAST"), + ] + }, ) npf = Npf( @@ -453,10 +457,14 @@ def test_gwf_disv_uzf(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head={0: ["all"]}, - save_budget={0: ["all"]}, - print_head={0: ["all"]}, - print_budget={0: ["all"]}, + stress_period_data={ + 0: [ + ("SAVE", "HEAD", "ALL"), + ("SAVE", "BUDGET", "ALL"), + ("PRINT", "HEAD", "ALL"), + ("PRINT", "BUDGET", "ALL"), + ] + }, ) npf = Npf( @@ -552,8 +560,7 @@ def test_quickstart(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.bud", head_file=f"{gwf_name}.hds", - save_head=["all"], - save_budget=["all"], + stress_period_data={0: [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", "ALL")]}, ) npf = Npf(parent=gwf, icelltype=0, k=1.0) chd = Chd(parent=gwf, stress_period_data={0: [[(0, 0, 0), 1.0], [(0, 9, 9), 0.0]]}) @@ -600,8 +607,7 @@ def test_quickstart_grid(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.bud", head_file=f"{gwf_name}.hds", - save_head=["all"], - save_budget=["all"], + stress_period_data={0: [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", "ALL")]}, ) npf = Npf(parent=gwf, icelltype=0, k=1.0) @@ -659,8 +665,7 @@ def test_quickstart_netcdf(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.bud", head_file=f"{gwf_name}.hds", - save_head=["all"], - save_budget=["all"], + stress_period_data={0: [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", "ALL")]}, ) npf = Npf(parent=gwf, icelltype=0, k=1.0) @@ -772,8 +777,7 @@ def test_quickstart_netcdf_mesh(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.bud", head_file=f"{gwf_name}.hds", - save_head=["all"], - save_budget=["all"], + stress_period_data={0: [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", "ALL")]}, ) npf = Npf(parent=gwf, icelltype=0, k=1.0) @@ -877,8 +881,7 @@ def test_gwf_wel(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head=["last"], - save_budget=["last"], + stress_period_data={0: [("SAVE", "HEAD", "LAST"), ("SAVE", "BUDGET", "LAST")]}, ) chd = Chd( parent=gwf, stress_period_data={0: [[(0, 0, 0), 5.0], [(0, 0, 9), 5.0]]}, name="chd-1" @@ -924,8 +927,7 @@ def test_gwf_drn(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head=["last"], - save_budget=["last"], + stress_period_data={0: [("SAVE", "HEAD", "LAST"), ("SAVE", "BUDGET", "LAST")]}, ) chd = Chd(parent=gwf, stress_period_data={0: [[(0, 0, 0), 5.0]]}, name="chd-1") drn = Drn( @@ -973,8 +975,7 @@ def test_gwf_riv(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head=["last"], - save_budget=["last"], + stress_period_data={0: [("SAVE", "HEAD", "LAST"), ("SAVE", "BUDGET", "LAST")]}, ) chd = Chd(parent=gwf, stress_period_data={0: [[(0, 0, 0), 3.0]]}, name="chd-1") riv = Riv( @@ -1022,8 +1023,7 @@ def test_gwf_rch(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head=["last"], - save_budget=["last"], + stress_period_data={0: [("SAVE", "HEAD", "LAST"), ("SAVE", "BUDGET", "LAST")]}, ) chd = Chd( parent=gwf, @@ -1082,8 +1082,7 @@ def test_gwf_rcha(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head=["last"], - save_budget=["last"], + stress_period_data={0: [("SAVE", "HEAD", "LAST"), ("SAVE", "BUDGET", "LAST")]}, ) chd = Chd( parent=gwf, @@ -1137,8 +1136,7 @@ def test_gwf_evt(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head=["last"], - save_budget=["last"], + stress_period_data={0: [("SAVE", "HEAD", "LAST"), ("SAVE", "BUDGET", "LAST")]}, ) chd = Chd( parent=gwf, @@ -1199,8 +1197,7 @@ def test_gwf_evta(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head=["last"], - save_budget=["last"], + stress_period_data={0: [("SAVE", "HEAD", "LAST"), ("SAVE", "BUDGET", "LAST")]}, ) chd = Chd( parent=gwf, @@ -1265,8 +1262,7 @@ def test_gwf_mvr(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head=["last"], - save_budget=["last"], + stress_period_data={0: [("SAVE", "HEAD", "LAST"), ("SAVE", "BUDGET", "LAST")]}, ) # xattree appends the 0-based index for list-typed children, so Chd/Wel/Drn # get auto-names chd0/wel0/drn0. Explicit name= would be mangled (e.g. "wel-1" -> "wel-10"). @@ -1290,9 +1286,15 @@ def test_gwf_mvr(function_tmpdir): stress_period_data={ 0: { "pname1": np.array(["wel0"], dtype=object), - "id1": np.array([1], dtype=np.int64), + # 0-based Python-side index (the first boundary in wel0/ + # drn0's own list) -- id1/id2 are DFN numeric_index fields + # (gwf-mvr.dfn: "the first well has an identifier of one"), + # so MF6's own 1-based file convention comes from the same + # +1-at-write-time conversion every other index/fk column + # uses, not a value supplied 1-based directly. + "id1": np.array([0], dtype=np.int64), "pname2": np.array(["drn0"], dtype=object), - "id2": np.array([1], dtype=np.int64), + "id2": np.array([0], dtype=np.int64), "mvrtype": np.array(["FACTOR"], dtype=object), "value": np.array([0.5]), } @@ -1369,8 +1371,7 @@ def test_gwt_basic(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head=["last"], - save_budget=["last"], + stress_period_data={0: [("SAVE", "HEAD", "LAST"), ("SAVE", "BUDGET", "LAST")]}, ) Chd( parent=gwf, @@ -1455,8 +1456,7 @@ def test_gwe_basic(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head=["last"], - save_budget=["last"], + stress_period_data={0: [("SAVE", "HEAD", "LAST"), ("SAVE", "BUDGET", "LAST")]}, ) Chd( parent=gwf, @@ -1541,8 +1541,7 @@ def test_gwf_buy(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head=["last"], - save_budget=["last"], + stress_period_data={0: [("SAVE", "HEAD", "LAST"), ("SAVE", "BUDGET", "LAST")]}, ) # CHD with auxiliary "conc" so BUY can read concentration per stress record Chd( @@ -1632,8 +1631,7 @@ def test_gwf_vsc(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head=["last"], - save_budget=["last"], + stress_period_data={0: [("SAVE", "HEAD", "LAST"), ("SAVE", "BUDGET", "LAST")]}, ) # CHD with auxiliary "temperature" so VSC can read temp per stress record Chd( @@ -1732,8 +1730,7 @@ def test_prt_basic(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head=["last"], - save_budget=["last"], + stress_period_data={0: [("SAVE", "HEAD", "LAST"), ("SAVE", "BUDGET", "LAST")]}, ) Chd(parent=gwf, stress_period_data={0: [[(0, 0, 0), 5.0], [(0, 0, 4), 3.0]]}) gwf_sim.write() @@ -1774,14 +1771,21 @@ def test_prt_basic(function_tmpdir): def test_gwf_oc_period_variations(function_tmpdir): - """OC period dict: verify stop-sentinel and STEPS produce correct CBC record counts. + """OC stress_period_data: verify an explicit empty period and STEPS produce + correct CBC record counts. Uses a minimal 3-period, 1-layer, 3x3 model with: - - save_budget={0: "STEPS 1", 1: ""}: budget only for period 1, step 1 - - save_head={"*": "all"}: head every timestep, all periods - - Asserts the CBC has exactly 1 FLOW-JA-FACE record (stop sentinel halts fill-forward) - and the HDS has records for all three periods. + - period 0: SAVE HEAD ALL + SAVE BUDGET STEPS 1 (budget only at step 1). + - period 1: SAVE HEAD ALL only -- a fresh period block that drops the + STEPS 1 budget setting so it doesn't continue into periods 2/3, the + new representation's equivalent of the old per-rtype dict API's "" stop + sentinel. MF6 OC persists settings at whole-period-block granularity, + not per line: an explicit period block replaces the previous block's + settings entirely, so SAVE HEAD ALL must be repeated here for head to + keep being saved (it then carries forward to period 2 on its own). + + Asserts the CBC has exactly 1 FLOW-JA-FACE record (the period 1 block + halts the budget setting) and the HDS has records for all three periods. """ from flopy4.mf6.utils import open_cbc, open_hds @@ -1818,8 +1822,10 @@ def test_gwf_oc_period_variations(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head={"*": "all"}, - save_budget={0: "STEPS 1", 1: ""}, + stress_period_data={ + 0: [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", "STEPS", 1)], + 1: [("SAVE", "HEAD", "ALL")], + }, dims={"nper": nper}, ) @@ -1893,8 +1899,7 @@ def test_gwt_ssm_sources(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head=["last"], - save_budget=["last"], + stress_period_data={0: [("SAVE", "HEAD", "LAST"), ("SAVE", "BUDGET", "LAST")]}, ) # CHD with auxiliary "conc": left inflow at concentration 1.0, right outflow 0.0 chd = Chd( @@ -2020,10 +2025,14 @@ def test_gwf_lak_status(function_tmpdir): parent=gwf, head_file=f"{gwf_name}.hds", budget_file=f"{gwf_name}.cbc", - save_head={0: ["all"]}, - save_budget={0: ["all"]}, - print_head={0: ["all"]}, - print_budget={0: ["all"]}, + stress_period_data={ + 0: [ + ("SAVE", "HEAD", "ALL"), + ("SAVE", "BUDGET", "ALL"), + ("PRINT", "HEAD", "ALL"), + ("PRINT", "BUDGET", "ALL"), + ] + }, ) chd = Chd( parent=gwf, @@ -2195,8 +2204,7 @@ def test_gwt_lkt01(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head={0: ["ALL"]}, - save_budget={0: ["ALL"]}, + stress_period_data={0: [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", "ALL")]}, ) Chd( parent=gwf, @@ -2290,9 +2298,13 @@ def test_gwt_lkt01(function_tmpdir): parent=gwt, budget_file=f"{gwt_name}.cbc", concentration_file=f"{gwt_name}.ucn", - save_concentration={0: ["ALL"]}, - print_concentration={0: ["ALL"]}, - print_budget={0: ["ALL"]}, + stress_period_data={ + 0: [ + ("SAVE", "CONCENTRATION", "ALL"), + ("PRINT", "CONCENTRATION", "ALL"), + ("PRINT", "BUDGET", "ALL"), + ] + }, ) sim.write() @@ -2401,8 +2413,7 @@ def test_gwt_lkt_flow_package_auxiliary_name(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head={0: ["ALL"]}, - save_budget={0: ["ALL"]}, + stress_period_data={0: [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", "ALL")]}, ) Chd( parent=gwf, @@ -2495,9 +2506,13 @@ def test_gwt_lkt_flow_package_auxiliary_name(function_tmpdir): parent=gwt, budget_file=f"{gwt_name}.cbc", concentration_file=f"{gwt_name}.ucn", - save_concentration={0: ["ALL"]}, - print_concentration={0: ["ALL"]}, - print_budget={0: ["ALL"]}, + stress_period_data={ + 0: [ + ("SAVE", "CONCENTRATION", "ALL"), + ("PRINT", "CONCENTRATION", "ALL"), + ("PRINT", "BUDGET", "ALL"), + ] + }, ) sim.write() @@ -2606,8 +2621,7 @@ def test_gwe_lke_flow_package_auxiliary_name(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head={0: ["ALL"]}, - save_budget={0: ["ALL"]}, + stress_period_data={0: [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", "ALL")]}, ) Chd( parent=gwf, @@ -2698,9 +2712,13 @@ def test_gwe_lke_flow_package_auxiliary_name(function_tmpdir): parent=gwe, budget_file=f"{gwe_name}.cbc", temperature_file=f"{gwe_name}.utn", - save_temperature={0: ["ALL"]}, - print_temperature={0: ["ALL"]}, - print_budget={0: ["ALL"]}, + stress_period_data={ + 0: [ + ("SAVE", "TEMPERATURE", "ALL"), + ("PRINT", "TEMPERATURE", "ALL"), + ("PRINT", "BUDGET", "ALL"), + ] + }, ) sim.write() diff --git a/test/mf6/test_mf6_namefile_load.py b/test/mf6/test_mf6_namefile_load.py index 590123e6..b8971411 100644 --- a/test/mf6/test_mf6_namefile_load.py +++ b/test/mf6/test_mf6_namefile_load.py @@ -54,8 +54,7 @@ def written_sim(tmp_path): gwf.oc = Oc( budget_file=f"{gwf.name}.bud", head_file=f"{gwf.name}.hds", - save_head={0: "all"}, - save_budget={0: "all"}, + stress_period_data={0: [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", "ALL")]}, ) sim.write() return workspace @@ -125,14 +124,18 @@ def test_load_gwf_directly(written_sim): def test_load_preserves_model_pname(tmp_path): """A dict-kind binding field (Simulation.models/exchanges/solutions) - round-trips a custom pname -- xattree reconciles a dict child's name - to the key it's attached under, so the namefile row's pname (not the - referenced file's name, which the row's pname needn't match) has to - become that key. Scalar/list package fields (dis, chd, ...) can't - round-trip a custom pname the same way: xattree reconciles those to a - field-derived name regardless of what's passed, confirmed true even - for the original write (not something this fix could or should - change -- it's xattree's own child-attachment convention).""" + round-trips a custom pname via xattree's own `.name` -- xattree + reconciles a dict child's name to the key it's attached under, so the + namefile row's pname (not the referenced file's name, which the row's + pname needn't match) has to become that key. List/only-kind package + fields (dis, chd, ...) can't round-trip a custom pname through `.name` + the same way: xattree reconciles those to a field-derived name + regardless of what's passed, confirmed true even for the original + write (not something this fix could or should change -- it's + xattree's own child-attachment convention). See + `test_load_preserves_list_package_pname` below for how those still + round-trip a pname -- through the separate, xattree-unmanaged + `Component.pname` field, not `.name`.""" import numpy as np from flopy.discretization.structuredgrid import StructuredGrid @@ -158,6 +161,31 @@ def test_load_preserves_model_pname(tmp_path): assert gwf.name == "a_custom_model_name" +def test_load_preserves_list_package_pname(written_sim): + """A list-kind package field's real pname (a namefile packages-block + row's third term) survives a load -> write round trip via + `Component.pname`, a plain field xattree doesn't manage or reconcile + -- even though xattree's own `.name` attribute still gets reconciled + to a field-derived value ("chd0") regardless, same as always. Without + `Component.pname`, a hand-given pname like "boundary_west" below + would silently become "chd0" on the very first write after loading. + """ + nam_path = written_sim / "mymodel.nam" + nam_path.write_text( + nam_path.read_text().replace("CHD6 mymodel.chd chd0", "CHD6 mymodel.chd boundary_west") + ) + + gwf = next(iter(Simulation.load(written_sim / "mfsim.nam").models.values())) + chd = gwf.chd[0] + assert isinstance(chd, Chd) + assert chd.pname == "boundary_west" + assert chd.name == "chd0" # xattree's own reconciliation, unaffected + + gwf.write() + rewritten = (written_sim / "mymodel.nam").read_text() + assert "CHD6 mymodel.chd boundary_west" in rewritten + + def test_load_disambiguates_ga_variant(tmp_path): """Real MF6 writes "CHD6" for both Chd and Chdg (see component_ftype()'s docstring) -- the namefile row alone can't tell diff --git a/test/mf6/test_mf6_output_readers.py b/test/mf6/test_mf6_output_readers.py index 2c138e08..1cac7bd5 100644 --- a/test/mf6/test_mf6_output_readers.py +++ b/test/mf6/test_mf6_output_readers.py @@ -55,8 +55,7 @@ def dis_model_output(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head=["last"], - save_budget=["last"], + stress_period_data={0: [("SAVE", "HEAD", "LAST"), ("SAVE", "BUDGET", "LAST")]}, ) sim.write() @@ -157,8 +156,7 @@ def disv_model_output(function_tmpdir): parent=gwf, budget_file=f"{gwf_name}.cbc", head_file=f"{gwf_name}.hds", - save_head=["last"], - save_budget=["last"], + stress_period_data={0: [("SAVE", "HEAD", "LAST"), ("SAVE", "BUDGET", "LAST")]}, ) sim.write()