Skip to content

Commit cedc1af

Browse files
committed
Improve WEL file handling and naming in Dash app
Adds lazy loading for MFUSG WEL files, improving performance for large datasets. Refactors period key access with a new get_wel_period_keys utility, updates dataset naming to use a hash of change parameters, and enhances resource publishing to use output filenames. Also adds a helper to flatten single nested directories after downloads and improves period summary display in the UI.
1 parent 55d94dc commit cedc1af

6 files changed

Lines changed: 171 additions & 35 deletions

File tree

dash/Dockerfile.dash

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,4 @@ COPY flopy_wel_map.py /app/dash/
2626

2727
EXPOSE 8050
2828

29-
CMD ["gunicorn", "--bind", "0.0.0.0:8050", "dash_app:server"]
29+
CMD ["gunicorn", "--bind", "0.0.0.0:8050", "--timeout", "600", "dash_app:server"]

dash/dash_app.py

Lines changed: 55 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import uuid
99
from pathlib import Path
1010
import re
11+
import hashlib
12+
import json
1113
from typing import Dict, Iterable, List, Sequence
1214

1315
import dash
@@ -26,14 +28,15 @@
2628
search_ckan_datasets,
2729
search_ckan_datasets_wel_rch,
2830
)
29-
from flopy_interactive.config import GRID_STANDARD_VAR
31+
from flopy_interactive.config import CKAN_BASE_URL, GRID_STANDARD_VAR
3032
from flopy_interactive.data.download import download_ckan_resource
3133
from flopy_interactive.data.grid import load_grid_resource
3234
from flopy_interactive.data.rch import apply_rch_rate_update, build_rch_cells_for_periods, load_rch
3335
from flopy_interactive.data.wel import (
3436
apply_rate_update,
3537
build_cell_id_lookup,
3638
collect_wel_cells_for_period_data,
39+
get_wel_period_keys,
3740
load_wel,
3841
)
3942
from flopy_interactive.viz.color_modes import apply_color_mode
@@ -42,6 +45,7 @@
4245
DATA_DIR = Path(os.environ.get("FLOPY_DATA_DIR", "ckan_data"))
4346
OUTPUT_WEL = Path(os.environ.get("FLOPY_OUTPUT_WEL", "barton_springs_updated.wel"))
4447
SUGGEST_TITLE_FILTER = "Barton Springs Edwards Aquifer"
48+
CKAN_URL = os.environ.get("FLOPY_CKAN_URL", CKAN_BASE_URL)
4549

4650

4751
def get_datasets() -> List[Dict]:
@@ -362,6 +366,22 @@ def _dataset_options_without_grid(datasets: List[Dict]) -> List[Dict[str, str]]:
362366
return filtered
363367

364368

369+
def _summarize_periods(periods: List[int], total: int | None) -> str:
370+
"""Return a compact stress-period summary string."""
371+
if not periods:
372+
return f"All periods ({total})" if total else "All periods"
373+
unique = sorted({int(p) for p in periods})
374+
if total and len(unique) >= total:
375+
return f"All periods ({total})"
376+
if total and total > 0 and len(unique) / total >= 0.7:
377+
return f"Periods: {len(unique)}/{total}"
378+
if len(unique) > 1 and unique[-1] - unique[0] + 1 == len(unique):
379+
return f"Periods: {unique[0] + 1}-{unique[-1] + 1}"
380+
if len(unique) <= 5:
381+
return "Periods: " + ", ".join(str(p + 1) for p in unique)
382+
return "Periods: " + ", ".join(str(p + 1) for p in unique[:3]) + f" (+{len(unique) - 3} more)"
383+
384+
365385
def _downsample_for_choropleth(gdf_valid, gdf_full, zoom: float | None) -> pd.DataFrame:
366386
"""Downsample grid polygons for choropleth rendering based on zoom."""
367387
if zoom is None:
@@ -685,8 +705,7 @@ def update_dataset_controls(loaded_dataset: str | None):
685705
return [], []
686706
data = load_dataset(loaded_dataset)
687707
wel = data["wel"]
688-
spd = wel.stress_period_data.data
689-
period_keys = sorted(list(spd.keys())) if spd else [0]
708+
period_keys = get_wel_period_keys(wel) or [0]
690709
period_options = [{"label": f"SP {idx + 1}", "value": idx} for idx in period_keys]
691710
nlay = data["nlay"]
692711
layer_options = [{"label": str(layer), "value": layer} for layer in range(1, nlay + 1)]
@@ -709,8 +728,7 @@ def update_color_period(loaded_dataset, color_by, current_value):
709728
return {"display": "none"}, [], None
710729
data = load_dataset(loaded_dataset)
711730
wel = data["wel"]
712-
spd = getattr(wel, "stress_period_data", None)
713-
spd_keys = sorted(list(spd.data.keys())) if spd is not None and hasattr(spd, "data") else [0]
731+
spd_keys = get_wel_period_keys(wel) or [0]
714732
options = [{"label": f"SP {idx + 1}", "value": idx} for idx in spd_keys]
715733
if current_value in spd_keys:
716734
value = current_value
@@ -788,8 +806,7 @@ def update_periods_layers(
788806
if not period_options or not layer_options:
789807
data = load_dataset(loaded_dataset)
790808
wel = data["wel"]
791-
spd = wel.stress_period_data.data
792-
period_keys = sorted(list(spd.keys())) if spd else [0]
809+
period_keys = get_wel_period_keys(wel) or [0]
793810
period_options = [
794811
{"label": f"SP {idx + 1}", "value": idx} for idx in period_keys
795812
]
@@ -925,6 +942,7 @@ def update_dataset_suggestions(username, jwt_token):
925942
Input("load-counter", "data"),
926943
State("name-seed", "data"),
927944
State("last-loaded-dataset", "data"),
945+
State("periods", "options"),
928946
State("dataset-name", "value"),
929947
State("output-wel", "value"),
930948
State("source-url", "value"),
@@ -946,6 +964,7 @@ def suggest_names(
946964
_load_counter,
947965
name_seed,
948966
last_loaded_dataset,
967+
period_options,
949968
current_dataset_name,
950969
current_output_name,
951970
current_source_url,
@@ -992,33 +1011,34 @@ def suggest_names(
9921011
suffix = "0% change"
9931012
else:
9941013
suffix = f"set-{rate_value:.0f}"
995-
base_name = _slugify(f"{loaded_dataset}-{name_seed}")
1014+
period_total = len(period_options or [])
1015+
period_summary = _summarize_periods(list(periods or []), period_total or None)
1016+
change_spec = {
1017+
"flux_source": flux_source,
1018+
"rate_mode": rate_mode,
1019+
"new_rate": rate_value,
1020+
"periods": sorted(list(periods or [])),
1021+
"layers": sorted(list(layers or [])),
1022+
"add_missing": bool(add_missing),
1023+
"selection_count": len(selected_ids or []),
1024+
"color_by": color_by,
1025+
"category": category_value,
1026+
}
1027+
change_hash = hashlib.sha1(json.dumps(change_spec, sort_keys=True).encode("utf-8")).hexdigest()[:8]
1028+
base_name = _slugify(f"{loaded_dataset}-{change_hash}")
9961029
dataset_name = current_dataset_name or base_name
9971030
output_ext = ".rch" if flux_source == "rch" else ".wel"
998-
output_name = f"{loaded_dataset}_{suffix}{output_ext}"
1031+
output_name = f"{loaded_dataset}_{suffix}_{change_hash}{output_ext}"
9991032
if not output_name.lower().endswith(output_ext):
10001033
output_name = f"{Path(output_name).stem}{output_ext}"
10011034
source_url = current_source_url
1002-
if not source_url and jwt_token and loaded_dataset:
1003-
try:
1004-
details = ckanp.package_show(jwt_token, loaded_dataset)
1005-
source_url = details.get("url")
1006-
if not source_url:
1007-
resources = details.get("resources", [])
1008-
target_var = ckanp.RCH_STANDARD_VAR if flux_source == "rch" else ckanp.WEL_STANDARD_VAR
1009-
target_res = next(
1010-
(res for res in resources if resource_has_standard_var(res, target_var)),
1011-
None,
1012-
)
1013-
if target_res:
1014-
source_url = target_res.get("url")
1015-
except Exception:
1016-
source_url = current_source_url
1035+
if not source_url and loaded_dataset:
1036+
source_url = f"{CKAN_URL}/dataset/{loaded_dataset}"
10171037
selection_count = len(selected_ids or [])
10181038
selection_desc = f"Selected cells: {selection_count}"
10191039
if color_by in ("GCD_Name", "PGMA_Name") and category_value:
10201040
selection_desc = f"Category {color_by} = {category_value}"
1021-
period_desc = "All periods" if not periods else f"Periods: {', '.join(str(p) for p in periods)}"
1041+
period_desc = period_summary
10221042
if flux_source == "rch":
10231043
layer_desc = "Layers: n/a"
10241044
add_desc = "Add missing wells: n/a"
@@ -1234,6 +1254,16 @@ def apply_rate(
12341254
data = load_dataset(loaded_dataset)
12351255
wel = data["wel"]
12361256
gdf = data["gdf"]
1257+
print(
1258+
"[apply] "
1259+
f"dataset={loaded_dataset} flux_source={flux_source} "
1260+
f"rate_mode={rate_mode} new_rate={new_rate} "
1261+
f"periods={periods} layers={layers} add_missing={add_missing} "
1262+
f"selected_count={len(selected_ids)} "
1263+
f"dataset_name={dataset_name} output={output_wel} "
1264+
f"source_url={source_url} change_summary={change_summary} "
1265+
f"jwt={'yes' if jwt_token else 'no'}"
1266+
)
12371267
output_path = Path(output_wel or OUTPUT_WEL)
12381268
target_ext = ".rch" if flux_source == "rch" else ".wel"
12391269
if output_path.suffix.lower() != target_ext:

flopy_interactive/app/notebook_ui.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
apply_rate_update,
2323
build_cell_id_lookup,
2424
collect_wel_cells_for_period_data,
25+
get_wel_period_keys,
2526
load_wel,
2627
)
2728
from flopy_interactive.viz.color_modes import apply_color_mode, update_flux_customdata
@@ -121,9 +122,7 @@ def _collect_wel_cells_for_period(
121122
value="flux",
122123
description="Color by",
123124
)
124-
spd_keys = sorted(list(wel.stress_period_data.data.keys()))
125-
if not spd_keys:
126-
spd_keys = [0]
125+
spd_keys = get_wel_period_keys(wel) or [0]
127126
period_options = [(f"SP {idx + 1}", idx) for idx in spd_keys]
128127
period_select = widgets.SelectMultiple(
129128
options=period_options,

flopy_interactive/ckankit/publish.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ def build_resource_payload(
325325
change_summary: str | None = None,
326326
default_name: str = "WEL",
327327
default_format: str = "WEL",
328+
resource_name: str | None = None,
328329
) -> Dict:
329330
"""Create a resource payload derived from a source resource.
330331
@@ -358,7 +359,7 @@ def build_resource_payload(
358359
if change_summary:
359360
description = f"{description}\nMetadata Description of Changes Made: {change_summary}".strip()
360361
return {
361-
"name": f"{source_resource.get('name', default_name)} (updated)",
362+
"name": resource_name or f"{source_resource.get('name', default_name)} (updated)",
362363
"description": description,
363364
"format": source_resource.get("format", default_format),
364365
"mint_standard_variables": mint_svo,
@@ -456,6 +457,7 @@ def publish_updated_wel(
456457
mint_svo,
457458
source_url=source_url,
458459
change_summary=change_summary,
460+
resource_name=f"{output_path.stem}",
459461
)
460462
created_resource = create_resource_upload(
461463
jwt_token,
@@ -558,6 +560,7 @@ def publish_updated_rch(
558560
change_summary=change_summary,
559561
default_name="RCH",
560562
default_format="RCH",
563+
resource_name=f"{output_path.stem}",
561564
)
562565
created_resource = create_resource_upload(
563566
jwt_token,

flopy_interactive/data/download.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,21 @@ def download_ckan_resource(resource: Dict, dest_dir: Path) -> Path:
3434
return dest_path
3535

3636

37+
def _flatten_single_dir(root: Path) -> None:
38+
"""Flatten a single nested directory in place."""
39+
if not root.is_dir():
40+
return
41+
entries = list(root.iterdir())
42+
subdirs = [entry for entry in entries if entry.is_dir()]
43+
files = [entry for entry in entries if entry.is_file()]
44+
if files or len(subdirs) != 1:
45+
return
46+
nested = subdirs[0]
47+
for entry in nested.iterdir():
48+
shutil.move(str(entry), root / entry.name)
49+
nested.rmdir()
50+
51+
3752
def extract_zip(zip_path: Path) -> Path:
3853
"""Extract a zip to a folder alongside the archive.
3954

flopy_interactive/data/wel.py

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,48 @@ def write_file(self, output_path: Path) -> None:
4040
output_path.write_text("\n".join(lines) + "\n")
4141

4242

43+
class LazyMfusgWel(MfusgWel):
44+
"""Lazy MFUSG WEL reader that loads selected stress periods on demand."""
45+
46+
is_lazy = True
47+
48+
def __init__(self, path: Path, header_line: str, index: list, dtype: np.dtype) -> None:
49+
super().__init__(header_line, {}, dtype)
50+
self.path = path
51+
self.index = index
52+
self.period_count = len(index)
53+
54+
def get_period(self, period: int) -> np.recarray:
55+
if period < 0 or period >= self.period_count:
56+
return np.recarray(0, dtype=self.stress_period_data.dtype)
57+
entry = self.index[period]
58+
itmp = entry["itmp"]
59+
if itmp < 0:
60+
return self.get_period(period - 1) if period > 0 else np.recarray(0, dtype=self.stress_period_data.dtype)
61+
if itmp == 0:
62+
return np.recarray(0, dtype=self.stress_period_data.dtype)
63+
records = []
64+
with self.path.open() as handle:
65+
handle.seek(entry["offset"])
66+
for _ in range(itmp):
67+
line = _strip_comment(handle.readline())
68+
if not line:
69+
continue
70+
parts = line.split()
71+
if len(parts) < 2:
72+
continue
73+
node = int(float(parts[0]))
74+
flux = float(parts[1])
75+
records.append((node, flux))
76+
return np.rec.array(records, dtype=self.stress_period_data.dtype) if records else np.recarray(0, dtype=self.stress_period_data.dtype)
77+
78+
def load_all(self) -> Dict[int, np.recarray]:
79+
spd: Dict[int, np.recarray] = {}
80+
for per in range(self.period_count):
81+
spd[per] = self.get_period(per)
82+
return spd
83+
84+
4385
def _strip_comment(line: str) -> str:
4486
for token in ("#", ";"):
4587
if token in line:
@@ -102,6 +144,31 @@ def _load_mfusg_wel(wel_path: Path) -> MfusgWel:
102144
return MfusgWel(header, spd, dtype)
103145

104146

147+
def _index_mfusg_wel(wel_path: Path) -> LazyMfusgWel:
148+
"""Index MFUSG WEL file for lazy period access."""
149+
dtype = np.dtype([("node", "i4"), ("flux", "f8")])
150+
index = []
151+
with wel_path.open() as handle:
152+
header = _strip_comment(handle.readline()).rstrip("\n")
153+
per = 0
154+
while True:
155+
line = handle.readline()
156+
if not line:
157+
break
158+
line = _strip_comment(line)
159+
if not line:
160+
continue
161+
tokens = line.split()
162+
itmp = int(tokens[0])
163+
offset = handle.tell()
164+
if itmp > 0:
165+
for _ in range(itmp):
166+
handle.readline()
167+
index.append({"itmp": itmp, "offset": offset})
168+
per += 1
169+
return LazyMfusgWel(wel_path, header, index, dtype)
170+
171+
105172
def scan_wel_metadata(path: Path) -> Tuple[int, int, int, int]:
106173
"""Scan a MODFLOW WEL file for grid dimensions.
107174
@@ -150,7 +217,7 @@ def load_wel(wel_path: Path) -> flopy.modflow.ModflowWel:
150217
FloPy WEL package.
151218
"""
152219
if _detect_mfusg_wel(wel_path):
153-
return _load_mfusg_wel(wel_path)
220+
return _index_mfusg_wel(wel_path)
154221
nper, nlay, nrow, ncol = scan_wel_metadata(wel_path)
155222
model = flopy.modflow.Modflow(modelname="wel_read", model_ws=str(wel_path.parent))
156223
flopy.modflow.ModflowDis(
@@ -194,10 +261,19 @@ def collect_wel_cells_for_period_data(
194261
Mapping of CELL_ID to flux value.
195262
"""
196263
cells: Dict[int, float] = {}
197-
spd = wel.stress_period_data.data
198-
if period not in spd:
199-
return cells
200-
recs = spd[period]
264+
if hasattr(wel, "is_mfusg") and getattr(wel, "is_mfusg"):
265+
if hasattr(wel, "get_period"):
266+
recs = wel.get_period(period)
267+
else:
268+
spd = wel.stress_period_data.data
269+
if period not in spd:
270+
return cells
271+
recs = spd[period]
272+
else:
273+
spd = wel.stress_period_data.data
274+
if period not in spd:
275+
return cells
276+
recs = spd[period]
201277
for rec in recs:
202278
if hasattr(wel, "is_mfusg") and getattr(wel, "is_mfusg"):
203279
node = int(rec["node"])
@@ -247,6 +323,9 @@ def apply_rate_update(
247323
if hasattr(wel, "is_mfusg") and getattr(wel, "is_mfusg"):
248324
node_lookup = dict(zip(gdf["CELL_ID"], gdf["NODE_NUM"]))
249325
selected_cells = {int(node_lookup[cid]) for cid in selected_ids if cid in node_lookup}
326+
if hasattr(wel, "load_all"):
327+
spd = wel.load_all()
328+
wel.stress_period_data.data = spd
250329
else:
251330
cell_lookup = dict(zip(gdf["CELL_ID"], zip(gdf["ROW"], gdf["COL"])))
252331
selected_cells = {cell_lookup[cid] for cid in selected_ids if cid in cell_lookup}
@@ -310,3 +389,13 @@ def apply_rate_update(
310389
updated_spd[per] = np.rec.array(recs, dtype=base_dtype)
311390
wel.write_file(str(output_path))
312391
return len(selected_cells)
392+
393+
394+
def get_wel_period_keys(wel) -> list[int]:
395+
"""Return stress period indices for WEL, including lazy MFUSG."""
396+
if hasattr(wel, "period_count"):
397+
return list(range(int(wel.period_count)))
398+
spd = getattr(wel, "stress_period_data", None)
399+
if spd is not None and hasattr(spd, "data"):
400+
return sorted(list(spd.data.keys()))
401+
return []

0 commit comments

Comments
 (0)