Skip to content

Commit 079298e

Browse files
authored
mfgrdfile: add version and crs properties (#2711)
1 parent 012d0f1 commit 079298e

4 files changed

Lines changed: 375 additions & 98 deletions

File tree

autotest/test_binarygrid_util.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ def mfgrd_test_path(example_data_path):
1717

1818
def test_mfgrddis_MfGrdFile(mfgrd_test_path):
1919
grb = MfGrdFile(mfgrd_test_path / "nwtp3.dis.grb", verbose=True)
20+
assert grb.version == 1
21+
2022
nodes = grb.nodes
2123
ia = grb.ia
2224
shape = ia.shape[0]
@@ -30,6 +32,28 @@ def test_mfgrddis_MfGrdFile(mfgrd_test_path):
3032
modelgrid = grb.modelgrid
3133
assert isinstance(modelgrid, StructuredGrid)
3234

35+
assert grb.crs is None
36+
37+
38+
def test_mfgrddis_MfGrdFile_v2(mfgrd_test_path):
39+
grb = MfGrdFile(mfgrd_test_path / "flow_v2.dis.grb", verbose=True)
40+
assert grb.version == 2
41+
42+
nodes = grb.nodes
43+
ia = grb.ia
44+
shape = ia.shape[0]
45+
assert shape == nodes + 1, f"ia size ({shape}) not equal to {nodes + 1}"
46+
47+
nnz = ia[-1]
48+
ja = grb.ja
49+
shape = ja.shape[0]
50+
assert shape == nnz, f"ja size ({shape}) not equal to {nnz}"
51+
52+
modelgrid = grb.modelgrid
53+
assert isinstance(modelgrid, StructuredGrid)
54+
55+
assert grb.crs == "EPSG:26916"
56+
3357

3458
def test_mfgrddis_modelgrid(mfgrd_test_path):
3559
fn = mfgrd_test_path / "nwtp3.dis.grb"
@@ -62,6 +86,7 @@ def test_mfgrddis_modelgrid(mfgrd_test_path):
6286
def test_mfgrddisv_MfGrdFile(mfgrd_test_path):
6387
fn = mfgrd_test_path / "flow.disv.grb"
6488
grb = MfGrdFile(fn, verbose=True)
89+
assert grb.version == 1
6590

6691
nodes = grb.nodes
6792
ia = grb.ia
@@ -114,6 +139,7 @@ def test_mfgrddisv_modelgrid(mfgrd_test_path):
114139
def test_mfgrddisu_MfGrdFile(mfgrd_test_path):
115140
fn = mfgrd_test_path / "keating.disu.grb"
116141
grb = MfGrdFile(fn, verbose=True)
142+
assert grb.version == 1
117143

118144
nodes = grb.nodes
119145
ia = grb.ia
@@ -164,11 +190,13 @@ def test_mfgrddisu_modelgrid(mfgrd_test_path):
164190
def test_write_grb_instance_method(tmp_path, mfgrd_test_path):
165191
original_file = mfgrd_test_path / "nwtp3.dis.grb"
166192
grb_orig = MfGrdFile(original_file, verbose=False)
193+
_ = grb_orig.modelgrid # trigger _set_modelgrid so BOTM is reshaped before export
167194

168195
output_file = tmp_path / "test_instance.dis.grb"
169196
grb_orig.export(output_file, verbose=False)
170197

171198
grb_new = MfGrdFile(output_file, verbose=False)
199+
_ = grb_new.modelgrid
172200

173201
assert grb_new.grid_type == grb_orig.grid_type
174202
assert grb_new.nodes == grb_orig.nodes
@@ -191,6 +219,37 @@ def test_write_grb_instance_method(tmp_path, mfgrd_test_path):
191219
np.testing.assert_array_equal(grb_new.idomain, grb_orig.idomain)
192220

193221

222+
def test_write_grb_instance_method_disv(tmp_path, mfgrd_test_path):
223+
# disv_layered.disv.grb has distinct BOTM values per layer (-10/-20/-30),
224+
# which distinguishes C from F flatten order and guards against order regression.
225+
original_file = mfgrd_test_path / "disv_layered.disv.grb"
226+
grb_orig = MfGrdFile(original_file, verbose=False)
227+
_ = grb_orig.modelgrid # trigger _set_modelgrid so BOTM is reshaped before export
228+
229+
output_file = tmp_path / "test_instance.disv.grb"
230+
grb_orig.export(output_file, verbose=False)
231+
232+
grb_new = MfGrdFile(output_file, verbose=False)
233+
_ = grb_new.modelgrid
234+
235+
assert grb_new.grid_type == grb_orig.grid_type
236+
assert grb_new.nodes == grb_orig.nodes
237+
assert grb_new.nlay == grb_orig.nlay
238+
assert grb_new.ncpl == grb_orig.ncpl
239+
assert grb_new.nja == grb_orig.nja
240+
241+
np.testing.assert_allclose(grb_new.xorigin, grb_orig.xorigin)
242+
np.testing.assert_allclose(grb_new.yorigin, grb_orig.yorigin)
243+
np.testing.assert_allclose(grb_new.angrot, grb_orig.angrot)
244+
245+
np.testing.assert_allclose(grb_new.top, grb_orig.top)
246+
np.testing.assert_allclose(grb_new.bot, grb_orig.bot)
247+
248+
np.testing.assert_array_equal(grb_new.ia, grb_orig.ia)
249+
np.testing.assert_array_equal(grb_new.ja, grb_orig.ja)
250+
np.testing.assert_array_equal(grb_new.idomain, grb_orig.idomain)
251+
252+
194253
def test_write_grb_instance_method_precision_conversion(tmp_path, mfgrd_test_path):
195254
original_file = mfgrd_test_path / "nwtp3.dis.grb"
196255
grb = MfGrdFile(original_file, verbose=False)
@@ -396,3 +455,177 @@ def test_write_grb_disu_precision_conversion(tmp_path, mfgrd_test_path):
396455
# Double precision should match exactly (same precision as original)
397456
np.testing.assert_allclose(grb_double.top, grb.top, rtol=1e-12)
398457
np.testing.assert_allclose(grb_double.bot, grb.bot, rtol=1e-12)
458+
459+
460+
def test_write_grb_v2_roundtrip(tmp_path, mfgrd_test_path):
461+
"""Round-trip a version 2 GRB: version and CRS are preserved."""
462+
grb_orig = MfGrdFile(mfgrd_test_path / "flow_v2.dis.grb", verbose=False)
463+
assert grb_orig.version == 2
464+
assert grb_orig.crs == "EPSG:26916"
465+
466+
output_file = tmp_path / "flow_v2_copy.dis.grb"
467+
grb_orig.export(output_file, verbose=False)
468+
469+
grb_new = MfGrdFile(output_file, verbose=False)
470+
assert grb_new.version == 2
471+
assert grb_new.crs == "EPSG:26916"
472+
assert grb_new.nodes == grb_orig.nodes
473+
np.testing.assert_allclose(grb_new.top, grb_orig.top)
474+
np.testing.assert_allclose(grb_new.bot, grb_orig.bot)
475+
np.testing.assert_array_equal(grb_new.ia, grb_orig.ia)
476+
np.testing.assert_array_equal(grb_new.ja, grb_orig.ja)
477+
478+
479+
def test_write_grb_v1_upgrade_to_v2(tmp_path, mfgrd_test_path):
480+
"""Exporting a v1 GRB with crs= automatically produces a v2 file."""
481+
grb_orig = MfGrdFile(mfgrd_test_path / "nwtp3.dis.grb", verbose=False)
482+
assert grb_orig.version == 1
483+
assert grb_orig.crs is None
484+
485+
output_file = tmp_path / "nwtp3_v2.dis.grb"
486+
grb_orig.export(output_file, crs="EPSG:26916", verbose=False)
487+
488+
grb_new = MfGrdFile(output_file, verbose=False)
489+
assert grb_new.version == 2
490+
assert grb_new.crs == "EPSG:26916"
491+
assert grb_new.nodes == grb_orig.nodes
492+
np.testing.assert_array_equal(grb_new.ia, grb_orig.ia)
493+
494+
495+
def test_write_grb_v1_force_override(tmp_path, mfgrd_test_path):
496+
"""Explicit version=1 suppresses CRS even when crs= is provided."""
497+
grb = MfGrdFile(mfgrd_test_path / "nwtp3.dis.grb", verbose=False)
498+
output_file = tmp_path / "forced_v1.dis.grb"
499+
grb.export(output_file, version=1, crs="EPSG:26916")
500+
501+
grb_new = MfGrdFile(output_file, verbose=False)
502+
assert grb_new.version == 1
503+
assert grb_new.crs is None
504+
assert grb_new.nodes == grb.nodes
505+
506+
507+
def test_write_grb_v2_requires_crs(tmp_path, mfgrd_test_path):
508+
"""Requesting version=2 without a CRS raises ValueError."""
509+
grb = MfGrdFile(mfgrd_test_path / "nwtp3.dis.grb", verbose=False)
510+
with pytest.raises(ValueError, match="version=2 requires a CRS"):
511+
grb.export(tmp_path / "out.grb", version=2)
512+
513+
514+
def test_write_grb_v2_crs_too_long(tmp_path, mfgrd_test_path):
515+
"""A CRS string exceeding 5000 characters raises ValueError."""
516+
grb = MfGrdFile(mfgrd_test_path / "nwtp3.dis.grb", verbose=False)
517+
with pytest.raises(ValueError, match="exceeds the MODFLOW 6 maximum"):
518+
grb.export(tmp_path / "out.grb", crs="X" * 5001)
519+
520+
521+
def test_crs_to_string():
522+
"""_crs_to_string normalizes CRS inputs to strings."""
523+
from flopy.mf6.utils.binarygrid_util import _crs_to_string
524+
525+
# str pass-through
526+
assert _crs_to_string("EPSG:26916") == "EPSG:26916"
527+
assert _crs_to_string("some WKT...") == "some WKT..."
528+
529+
# int → EPSG authority string
530+
assert _crs_to_string(26916) == "EPSG:26916"
531+
532+
# pyproj.CRS with a known EPSG code → prefers short EPSG form
533+
pyproj = pytest.importorskip("pyproj")
534+
crs_obj = pyproj.CRS.from_epsg(26916)
535+
assert _crs_to_string(crs_obj) == "EPSG:26916"
536+
537+
# pyproj.CRS built from WKT that has an EPSG code still normalizes
538+
crs_from_wkt = pyproj.CRS.from_wkt(crs_obj.to_wkt())
539+
assert _crs_to_string(crs_from_wkt) == "EPSG:26916"
540+
541+
542+
def test_write_grb_v2_crs_int(tmp_path, mfgrd_test_path):
543+
"""Passing an integer EPSG code writes 'EPSG:N' to the GRB."""
544+
grb_orig = MfGrdFile(mfgrd_test_path / "nwtp3.dis.grb", verbose=False)
545+
output_file = tmp_path / "nwtp3_v2_int.dis.grb"
546+
grb_orig.export(output_file, crs=26916)
547+
548+
grb_new = MfGrdFile(output_file, verbose=False)
549+
assert grb_new.version == 2
550+
assert grb_new.crs == "EPSG:26916"
551+
552+
553+
def test_write_grb_v2_crs_pyproj(tmp_path, mfgrd_test_path):
554+
"""Passing a pyproj.CRS object normalizes to EPSG string in the GRB."""
555+
pyproj = pytest.importorskip("pyproj")
556+
grb_orig = MfGrdFile(mfgrd_test_path / "nwtp3.dis.grb", verbose=False)
557+
output_file = tmp_path / "nwtp3_v2_pyproj.dis.grb"
558+
grb_orig.export(output_file, crs=pyproj.CRS.from_epsg(26916))
559+
560+
grb_new = MfGrdFile(output_file, verbose=False)
561+
assert grb_new.version == 2
562+
assert grb_new.crs == "EPSG:26916"
563+
564+
565+
def test_write_grb_disv_v1_upgrade_to_v2(tmp_path, mfgrd_test_path):
566+
"""Upgrading a v1 DISV GRB to v2 preserves geometry and writes CRS."""
567+
grb_orig = MfGrdFile(mfgrd_test_path / "flow.disv.grb", verbose=False)
568+
assert grb_orig.version == 1
569+
assert grb_orig.crs is None
570+
571+
output_file = tmp_path / "flow_v2.disv.grb"
572+
grb_orig.export(output_file, crs="EPSG:26916")
573+
574+
grb_new = MfGrdFile(output_file, verbose=False)
575+
assert grb_new.version == 2
576+
assert grb_new.crs == "EPSG:26916"
577+
assert grb_new.grid_type == "DISV"
578+
assert grb_new.nodes == grb_orig.nodes
579+
assert grb_new.nja == grb_orig.nja
580+
np.testing.assert_array_equal(grb_new.ia, grb_orig.ia)
581+
np.testing.assert_array_equal(grb_new.ja, grb_orig.ja)
582+
np.testing.assert_allclose(grb_new.top, grb_orig.top)
583+
np.testing.assert_allclose(grb_new.bot, grb_orig.bot)
584+
np.testing.assert_allclose(
585+
grb_new._datadict["VERTICES"], grb_orig._datadict["VERTICES"]
586+
)
587+
np.testing.assert_allclose(grb_new._datadict["CELLX"], grb_orig._datadict["CELLX"])
588+
np.testing.assert_allclose(grb_new._datadict["CELLY"], grb_orig._datadict["CELLY"])
589+
590+
591+
def test_write_grb_disv_v2_roundtrip(tmp_path, mfgrd_test_path):
592+
"""Round-trip a v2 DISV GRB: version and CRS are preserved."""
593+
# First create a v2 DISV file by upgrading the v1 source
594+
v2_file = tmp_path / "flow_v2.disv.grb"
595+
MfGrdFile(mfgrd_test_path / "flow.disv.grb").export(v2_file, crs="EPSG:26916")
596+
597+
grb_orig = MfGrdFile(v2_file, verbose=False)
598+
assert grb_orig.version == 2
599+
600+
output_file = tmp_path / "flow_v2_copy.disv.grb"
601+
grb_orig.export(output_file, verbose=False)
602+
603+
grb_new = MfGrdFile(output_file, verbose=False)
604+
assert grb_new.version == 2
605+
assert grb_new.crs == "EPSG:26916"
606+
assert grb_new.nodes == grb_orig.nodes
607+
np.testing.assert_array_equal(grb_new.ia, grb_orig.ia)
608+
np.testing.assert_allclose(
609+
grb_new._datadict["VERTICES"], grb_orig._datadict["VERTICES"]
610+
)
611+
612+
613+
def test_write_grb_disu_v1_upgrade_to_v2(tmp_path, mfgrd_test_path):
614+
"""Upgrading a v1 DISU GRB to v2 preserves connectivity and writes CRS."""
615+
grb_orig = MfGrdFile(mfgrd_test_path / "flow.disu.grb", verbose=False)
616+
assert grb_orig.version == 1
617+
assert grb_orig.crs is None
618+
619+
output_file = tmp_path / "flow_v2.disu.grb"
620+
grb_orig.export(output_file, crs="EPSG:26916")
621+
622+
grb_new = MfGrdFile(output_file, verbose=False)
623+
assert grb_new.version == 2
624+
assert grb_new.crs == "EPSG:26916"
625+
assert grb_new.grid_type == "DISU"
626+
assert grb_new.nodes == grb_orig.nodes
627+
assert grb_new.nja == grb_orig.nja
628+
np.testing.assert_array_equal(grb_new.ia, grb_orig.ia)
629+
np.testing.assert_array_equal(grb_new.ja, grb_orig.ja)
630+
np.testing.assert_allclose(grb_new.top, grb_orig.top)
631+
np.testing.assert_allclose(grb_new.bot, grb_orig.bot)
3.89 KB
Binary file not shown.
5.05 KB
Binary file not shown.

0 commit comments

Comments
 (0)