Skip to content

Commit ada6e63

Browse files
authored
fix(lgrutil): remove duplicate hanging vertices (#2672)
Lgr.to_disv_gridprops() left duplicate hanging vertices in the generated grid. Fix it by adding deduplication logic after vertices are collected. Some variables to record hanging vertices already existed, seems like this was intended but left out of the implementation. Now the only substantive difference between cvfdutil.gridlist_to_disv_gridprops and the Lgr approach is that the former includes "wraparound" vertices, which MF6 considers unnecessary. Noticed this in MODFLOW-ORG/modflow6-examples#319 while switching to the Lgr approach. Some of the test code here will be very short-lived given #2667 but I'm including it just to bake it into the git log for the benefit of any future forensics
1 parent 1ad30ae commit ada6e63

3 files changed

Lines changed: 401 additions & 23 deletions

File tree

autotest/test_lgrutil.py

Lines changed: 351 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import pytest
33

44
from flopy.discretization import StructuredGrid
5+
from flopy.utils.cvfdutil import get_disv_gridprops, gridlist_to_verts
56
from flopy.utils.lgrutil import Lgr, LgrToDisv, SimpleRegularGrid
67

78

@@ -192,7 +193,7 @@ def test_lgr_hanging_vertices():
192193
assert "top" in gridprops
193194
assert "botm" in gridprops
194195
assert gridprops["ncpl"] == 17
195-
assert gridprops["nvert"] == 32
196+
assert gridprops["nvert"] == 28
196197
assert gridprops["nlay"] == 3
197198

198199
# test the lgr to disv class
@@ -204,10 +205,11 @@ def test_lgr_hanging_vertices():
204205
assert lgrtodisv.back_face_hanging[2, 1] == [12, 13, 14, 15]
205206
assert lgrtodisv.front_face_hanging[0, 1] == [0, 1, 2, 3]
206207

207-
assert lgrtodisv.iverts[1] == [1, 2, 6, 18, 17, 5]
208-
assert lgrtodisv.iverts[3] == [4, 5, 20, 24, 9, 8]
209-
assert lgrtodisv.iverts[4] == [6, 7, 11, 10, 27, 23]
210-
assert lgrtodisv.iverts[6] == [9, 29, 30, 10, 14, 13]
208+
# These cells have hanging vertices at parent-child boundaries
209+
assert lgrtodisv.iverts[1] == [1, 2, 6, 17, 16, 5] # top-center parent cell
210+
assert lgrtodisv.iverts[3] == [4, 5, 18, 22, 9, 8] # middle-left parent cell
211+
assert lgrtodisv.iverts[4] == [6, 7, 11, 10, 25, 21] # middle-right parent cell
212+
assert lgrtodisv.iverts[6] == [9, 26, 27, 10, 14, 13] # bottom-left parent cell
211213

212214
assert np.allclose(gridprops["top"], dz * np.ones((17,)))
213215

@@ -459,3 +461,347 @@ def test_simple_regular_grid_deprecation():
459461
# Verify backward compatibility attributes
460462
assert grid.xorigin == xorigin
461463
assert grid.yorigin == yorigin
464+
465+
466+
def test_lgr_matches_legacy_gridlist_to_disv_gridprops():
467+
"""
468+
Lgr.to_disv_gridprops() output should match
469+
legacy gridlist_to_disv_gridprops() output.
470+
"""
471+
# TODO: this will need to be removed soon, and the underlying
472+
# implementation inlined, when we remove this function.
473+
from flopy.utils.cvfdutil import gridlist_to_disv_gridprops
474+
475+
# 7x7 parent grid with 3x3 refinement in center
476+
nlay = 1
477+
nrow_p, ncol_p = 7, 7
478+
delr_p = delc_p = 100.0 * np.ones(7)
479+
top_p = np.zeros((nrow_p, ncol_p))
480+
botm_p = -100.0 * np.ones((nlay, nrow_p, ncol_p))
481+
482+
# legacy approach
483+
idomain_p = np.ones((nlay, nrow_p, ncol_p), dtype=int)
484+
idomain_p[:, 2:5, 2:5] = 0 # Center 3x3 inactive
485+
sg_parent_legacy = StructuredGrid(
486+
delc=delc_p,
487+
delr=delr_p,
488+
top=top_p,
489+
botm=botm_p,
490+
idomain=idomain_p,
491+
xoff=0.0,
492+
yoff=0.0,
493+
)
494+
nrow_c, ncol_c = 9, 9
495+
delr_c = delc_c = 100.0 / 3.0 * np.ones(9)
496+
top_c = np.zeros((nrow_c, ncol_c))
497+
botm_c = -100.0 * np.ones((nlay, nrow_c, ncol_c))
498+
idomain_c = np.ones((nlay, nrow_c, ncol_c), dtype=int)
499+
sg_child = StructuredGrid(
500+
delc=delc_c,
501+
delr=delr_c,
502+
top=top_c,
503+
botm=botm_c,
504+
idomain=idomain_c,
505+
xoff=200.0,
506+
yoff=200.0,
507+
)
508+
gridprops_legacy = gridlist_to_disv_gridprops([sg_parent_legacy, sg_child])
509+
510+
# lgr approach
511+
parent_grid = StructuredGrid(
512+
delc=delc_p,
513+
delr=delr_p,
514+
top=top_p,
515+
botm=botm_p,
516+
xoff=0.0,
517+
yoff=0.0,
518+
)
519+
refine_mask = np.ones((nlay, nrow_p, ncol_p))
520+
refine_mask[:, 2:5, 2:5] = 0
521+
lgr = Lgr.from_parent_grid(parent_grid, refine_mask, ncpp=3)
522+
gridprops_lgr = lgr.to_disv_gridprops()
523+
524+
# Note: legacy gridlist_to_disv_gridprops doesn't return nlay, top, botm
525+
# Only check common keys
526+
assert gridprops_legacy["ncpl"] == gridprops_lgr["ncpl"]
527+
assert gridprops_legacy["nvert"] == gridprops_lgr["nvert"]
528+
529+
# Verify vertex positions match (may be in different order)
530+
verts_legacy_set = {
531+
(round(v[1], 9), round(v[2], 9)) for v in gridprops_legacy["vertices"]
532+
}
533+
verts_lgr_set = {
534+
(round(v[1], 9), round(v[2], 9)) for v in gridprops_lgr["vertices"]
535+
}
536+
assert verts_legacy_set == verts_lgr_set
537+
538+
for icpl in range(gridprops_legacy["ncpl"]):
539+
nverts_legacy = gridprops_legacy["cell2d"][icpl][3]
540+
nverts_lgr = gridprops_lgr["cell2d"][icpl][3]
541+
assert nverts_legacy >= 3
542+
assert nverts_lgr >= 3
543+
544+
for icpl in range(gridprops_legacy["ncpl"]):
545+
xc_legacy, yc_legacy = gridprops_legacy["cell2d"][icpl][1:3]
546+
xc_lgr, yc_lgr = gridprops_lgr["cell2d"][icpl][1:3]
547+
assert np.allclose([xc_legacy, yc_legacy], [xc_lgr, yc_lgr])
548+
549+
# Note: top and botm are only in Lgr gridprops,
550+
# not in legacy gridlist_to_disv_gridprops.
551+
# Just verify they exist in the Lgr version
552+
assert "nlay" in gridprops_lgr
553+
assert "top" in gridprops_lgr
554+
assert "botm" in gridprops_lgr
555+
assert gridprops_lgr["nlay"] == nlay
556+
assert gridprops_lgr["top"].shape == (gridprops_lgr["ncpl"],)
557+
assert gridprops_lgr["botm"].shape == (nlay, gridprops_lgr["ncpl"])
558+
559+
560+
def test_hanging_vertex_algorithm_comparison():
561+
"""
562+
Compare the two hanging vertex placement algorithms in detail:
563+
1. to_cvfd() - reactive geometric algorithm (legacy)
564+
2. Lgr.find_hanging_vertices() - proactive structured algorithm (new)
565+
566+
This test creates a simple parent-child grid and analyzes the differences
567+
in vertex placement, connectivity, and grid structure. Intended mainly to
568+
be run manually to inspect the printed output.
569+
"""
570+
# Create a simple 3x3 parent grid with 3x refinement in center cell
571+
nlay = 1
572+
nrow_p = 3
573+
ncol_p = 3
574+
delr_p = 100.0
575+
delc_p = 100.0
576+
top_p = np.zeros((nrow_p, ncol_p))
577+
botm_p = np.array([np.full((nrow_p, ncol_p), -10.0)])
578+
579+
# Setup for both approaches
580+
# Legacy approach: use idomain to mark refinement region as inactive
581+
idomain_legacy = np.ones((nlay, nrow_p, ncol_p), dtype=int)
582+
idomain_legacy[:, 1, 1] = 0 # Center cell inactive
583+
584+
sg_parent_legacy = StructuredGrid(
585+
delr=np.full(ncol_p, delr_p),
586+
delc=np.full(nrow_p, delc_p),
587+
top=top_p,
588+
botm=botm_p,
589+
idomain=idomain_legacy,
590+
xoff=0.0,
591+
yoff=0.0,
592+
)
593+
594+
# Child grid at center position
595+
ncpp = 3
596+
top_c = np.zeros((ncpp, ncpp))
597+
botm_c = np.array([np.full((ncpp, ncpp), -10.0)])
598+
idomain_child = np.ones((nlay, ncpp, ncpp), dtype=int)
599+
sg_child = StructuredGrid(
600+
delr=np.full(ncpp, delr_p / ncpp),
601+
delc=np.full(ncpp, delc_p / ncpp),
602+
top=top_c,
603+
botm=botm_c,
604+
idomain=idomain_child,
605+
xoff=100.0,
606+
yoff=100.0,
607+
)
608+
609+
# New approach: use refine_mask
610+
sg_parent_new = StructuredGrid(
611+
delr=np.full(ncol_p, delr_p),
612+
delc=np.full(nrow_p, delc_p),
613+
top=top_p.copy(),
614+
botm=botm_p.copy(),
615+
xoff=0.0,
616+
yoff=0.0,
617+
)
618+
refine_mask = np.ones((nlay, nrow_p, ncol_p))
619+
refine_mask[:, 1, 1] = 0
620+
621+
# Generate grids with both algorithms
622+
# Legacy: to_cvfd() algorithm
623+
verts_legacy, iverts_legacy = gridlist_to_verts([sg_parent_legacy, sg_child])
624+
gridprops_legacy = get_disv_gridprops(verts_legacy, iverts_legacy)
625+
626+
# New: Lgr.find_hanging_vertices() algorithm
627+
lgr = Lgr.from_parent_grid(sg_parent_new, refine_mask, ncpp=ncpp)
628+
gridprops_new = lgr.to_disv_gridprops()
629+
630+
# Compare basic metrics
631+
print("\n" + "=" * 80)
632+
print("HANGING VERTEX ALGORITHM COMPARISON")
633+
print("=" * 80)
634+
print("\nGrid Configuration:")
635+
print(f" Parent: {nrow_p}x{ncol_p} cells, {delr_p}x{delc_p} spacing")
636+
print(f" Child: {ncpp}x{ncpp} refinement at center cell (1,1)")
637+
print(
638+
f" Total cells: {nrow_p * ncol_p - 1} "
639+
f"parent + {ncpp * ncpp} "
640+
f"child = {nrow_p * ncol_p - 1 + ncpp * ncpp}"
641+
)
642+
643+
print(
644+
f"\n{'Metric':<30} "
645+
f"{'Legacy (to_cvfd)':<20} "
646+
f"{'New (Lgr)':<20} "
647+
f"{'Difference':<20}"
648+
)
649+
print("-" * 90)
650+
print(
651+
f"{'Total vertices':<30} "
652+
f"{gridprops_legacy['nvert']:<20} "
653+
f"{gridprops_new['nvert']:<20} "
654+
f"{gridprops_new['nvert'] - gridprops_legacy['nvert']:<20}"
655+
)
656+
print(
657+
f"{'Total cells':<30} "
658+
f"{gridprops_legacy['ncpl']:<20} "
659+
f"{gridprops_new['ncpl']:<20} "
660+
f"{gridprops_new['ncpl'] - gridprops_legacy['ncpl']:<20}"
661+
)
662+
663+
# Analyze vertex differences
664+
verts_legacy_dict = {
665+
i: (v[1], v[2]) for i, v in enumerate(gridprops_legacy["vertices"])
666+
}
667+
verts_new_dict = {i: (v[1], v[2]) for i, v in enumerate(gridprops_new["vertices"])}
668+
669+
# Find unique vertices in each approach
670+
verts_legacy_set = {
671+
(round(x, 6), round(y, 6)) for x, y in verts_legacy_dict.values()
672+
}
673+
verts_new_set = {(round(x, 6), round(y, 6)) for x, y in verts_new_dict.values()}
674+
675+
only_in_legacy = verts_legacy_set - verts_new_set
676+
only_in_new = verts_new_set - verts_legacy_set
677+
common = verts_legacy_set & verts_new_set
678+
679+
print(f"\n{'Vertex Analysis':<30} {'Count':<20}")
680+
print("-" * 50)
681+
print(f"{'Common vertices':<30} {len(common):<20}")
682+
print(f"{'Only in legacy':<30} {len(only_in_legacy):<20}")
683+
print(f"{'Only in new':<30} {len(only_in_new):<20}")
684+
685+
if only_in_legacy:
686+
print("\nVertices only in legacy (to_cvfd):")
687+
for x, y in sorted(only_in_legacy):
688+
print(f" ({x:8.2f}, {y:8.2f})")
689+
690+
if only_in_new:
691+
print("\nVertices only in new (Lgr):")
692+
for x, y in sorted(only_in_new):
693+
print(f" ({x:8.2f}, {y:8.2f})")
694+
695+
# Analyze cell vertex counts
696+
legacy_cell_nverts = [cell[3] for cell in gridprops_legacy["cell2d"]]
697+
new_cell_nverts = [cell[3] for cell in gridprops_new["cell2d"]]
698+
699+
print(f"\n{'Cell Vertex Counts':<30} {'Legacy':<20} {'New':<20}")
700+
print("-" * 70)
701+
print(
702+
f"{'Min vertices per cell':<30} "
703+
f"{min(legacy_cell_nverts):<20} "
704+
f"{min(new_cell_nverts):<20}"
705+
)
706+
print(
707+
f"{'Max vertices per cell':<30} "
708+
f"{max(legacy_cell_nverts):<20} "
709+
f"{max(new_cell_nverts):<20}"
710+
)
711+
print(
712+
f"{'Avg vertices per cell':<30} "
713+
f"{np.mean(legacy_cell_nverts):<20.2f} "
714+
f"{np.mean(new_cell_nverts):<20.2f}"
715+
)
716+
717+
# Find cells with different vertex counts
718+
print("\nCells with different vertex counts:")
719+
diff_count = 0
720+
for icell in range(min(len(legacy_cell_nverts), len(new_cell_nverts))):
721+
if legacy_cell_nverts[icell] != new_cell_nverts[icell]:
722+
print(
723+
f" Cell {icell}: legacy={legacy_cell_nverts[icell]}, "
724+
f"new={new_cell_nverts[icell]}"
725+
)
726+
diff_count += 1
727+
if diff_count == 0:
728+
print(" None - all cells have same vertex counts")
729+
730+
# Identify hanging vertices by analyzing parent-child boundary cells
731+
print(f"\n{'Hanging Vertex Analysis':<50}")
732+
print("-" * 50)
733+
734+
# For each parent cell at the boundary, check vertex counts
735+
print("\nParent boundary cells (adjacent to refinement):")
736+
boundary_parents = [
737+
(0, "top-left"),
738+
(1, "top-center"),
739+
(2, "top-right"),
740+
(3, "middle-left"),
741+
(5, "middle-right"),
742+
(6, "bottom-left"),
743+
(7, "bottom-center"),
744+
(8, "bottom-right"),
745+
]
746+
747+
for icell, label in boundary_parents:
748+
if icell < len(legacy_cell_nverts):
749+
legacy_nv = legacy_cell_nverts[icell]
750+
new_nv = new_cell_nverts[icell]
751+
status = "DIFFERENT" if legacy_nv != new_nv else "same"
752+
print(
753+
f" Cell {icell} ({label:15s}): "
754+
f"legacy={legacy_nv}, new={new_nv} [{status}]"
755+
)
756+
757+
# Detailed vertex analysis - check for duplicates
758+
print(f"\n{'Duplicate Vertex Analysis':<50}")
759+
print("-" * 50)
760+
761+
# Count occurrences of each coordinate in new approach
762+
new_coord_counts = {}
763+
for i, (x, y) in verts_new_dict.items():
764+
coord = (round(x, 6), round(y, 6))
765+
if coord not in new_coord_counts:
766+
new_coord_counts[coord] = []
767+
new_coord_counts[coord].append(i)
768+
769+
duplicates_new = {
770+
coord: indices
771+
for coord, indices in new_coord_counts.items()
772+
if len(indices) > 1
773+
}
774+
if duplicates_new:
775+
print(f"Found {len(duplicates_new)} duplicate coordinates in new approach:")
776+
for coord, indices in sorted(duplicates_new.items()):
777+
print(f" {coord}: vertex indices {indices}")
778+
else:
779+
print("No duplicate coordinates in new approach")
780+
781+
# Count occurrences in legacy approach
782+
legacy_coord_counts = {}
783+
for i, (x, y) in verts_legacy_dict.items():
784+
coord = (round(x, 6), round(y, 6))
785+
if coord not in legacy_coord_counts:
786+
legacy_coord_counts[coord] = []
787+
legacy_coord_counts[coord].append(i)
788+
789+
duplicates_legacy = {
790+
coord: indices
791+
for coord, indices in legacy_coord_counts.items()
792+
if len(indices) > 1
793+
}
794+
if duplicates_legacy:
795+
print(
796+
f"Found {len(duplicates_legacy)} duplicate coordinates in legacy approach:"
797+
)
798+
for coord, indices in sorted(duplicates_legacy.items()):
799+
print(f" {coord}: vertex indices {indices}")
800+
else:
801+
print("No duplicate coordinates in legacy approach")
802+
803+
assert (
804+
gridprops_legacy["nvert"] == gridprops_new["nvert"]
805+
and len(only_in_legacy) == 0
806+
and len(only_in_new) == 0
807+
)

flopy/utils/cvfdutil.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -437,8 +437,9 @@ def gridlist_to_disv_gridprops(gridlist):
437437
"""
438438
warnings.warn(
439439
"the gridlist_to_disv_gridprops function is deprecated and will be "
440-
"removed in version 3.9. Use flopy.utils.cvfdutil.Lgr() instead, which "
441-
"allows a nested grid to be created and exported to a DISV mesh.",
440+
"removed in version 3.9. For nested grids, use flopy.utils.lgrutil.Lgr() "
441+
"instead. For converting a single structured grid to DISV, use "
442+
"get_disv_gridprops(grid.verts, grid.iverts) instead.",
442443
PendingDeprecationWarning,
443444
)
444445

0 commit comments

Comments
 (0)