Skip to content

Commit a576a98

Browse files
committed
Add README and improve docstrings and WEL update logic
Added a new README for the Dash app with setup and usage instructions. Added or improved docstrings throughout dash_app.py and ckan_publish.py for better code clarity. Updated flopy_wel_map.py to handle dtype preservation in WEL stress period data updates, ensuring correct array types and empty period handling.
1 parent 3eea1cc commit a576a98

4 files changed

Lines changed: 92 additions & 1 deletion

File tree

dash/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# FloPy Dash App
2+
3+
Dash dashboard for visualizing WEL/RCH data, selecting cells on a map, applying rate updates, and publishing updated WEL files back to CKAN with provenance metadata.
4+
5+
## Quick start
6+
7+
1. Create a virtual environment.
8+
2. Install dependencies:
9+
10+
```
11+
pip install -r dash/requirements-dash.txt
12+
```
13+
14+
3. Run the app:
15+
16+
```
17+
python dash/dash_app.py
18+
```
19+
20+
The app listens on http://localhost:8050 by default.
21+
22+
## Environment variables
23+
24+
- `FLOPY_DATA_DIR`: Directory where CKAN resources are downloaded (default: `ckan_data`).
25+
- `FLOPY_OUTPUT_WEL`: Default output WEL path (default: `barton_springs_updated.wel`).
26+
- `FLOPY_CKAN_URL`: CKAN base URL (default: `https://ckan.tacc.utexas.edu`).
27+
- `FLOPY_CKAN_JWT`: CKAN JWT to skip login flow.
28+
- `FLOPY_TAPIS_USERNAME`: Tapis username (used if `FLOPY_CKAN_JWT` is not set).
29+
- `FLOPY_TAPIS_PASSWORD`: Tapis password (used if `FLOPY_CKAN_JWT` is not set).
30+
31+
## App flow
32+
33+
- Select a dataset, flux source (WEL or RCH), and stress periods.
34+
- Lasso or click cells on the map to build a selection.
35+
- Set a rate update mode (set or scale) and apply changes.
36+
- Provide a dataset name, output filename, and change summary.
37+
- Click Apply + Save to write the updated WEL and publish to CKAN.
38+
39+
## Files
40+
41+
- `dash/dash_app.py`: Dash UI and callbacks.
42+
- `dash/ckan_publish.py`: CKAN/Tapis helpers and publish logic.
43+
- `dash/assets/style.css`: Dash styling.

dash/ckan_publish.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,20 @@
2121

2222

2323
def _now_iso() -> str:
24+
"""Return current UTC time in ISO8601 Zulu format."""
2425
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
2526

2627

2728
def _slugify(value: str) -> str:
29+
"""Normalize a string for CKAN dataset/resource naming."""
2830
value = value.strip().lower().replace(" ", "-")
2931
value = re.sub(r"[^a-z0-9\-_.]", "-", value)
3032
value = re.sub(r"-{2,}", "-", value)
3133
return value.strip("-") or "dataset"
3234

3335

3436
def _extras_to_dict(extras: Iterable[Dict]) -> Dict[str, str]:
37+
"""Convert CKAN extras list into a key/value dict."""
3538
result: Dict[str, str] = {}
3639
for item in extras or []:
3740
if not isinstance(item, dict):
@@ -44,12 +47,14 @@ def _extras_to_dict(extras: Iterable[Dict]) -> Dict[str, str]:
4447

4548

4649
def _merge_extras(existing: Iterable[Dict], additions: Dict[str, str]) -> List[Dict]:
50+
"""Merge extras with updates, returning CKAN list-of-dict format."""
4751
merged = _extras_to_dict(existing)
4852
merged.update({k: v for k, v in additions.items() if v is not None})
4953
return [{"key": key, "value": value} for key, value in merged.items()]
5054

5155

5256
def _extract_mint_svo(extras: Iterable[Dict]) -> Optional[str]:
57+
"""Find the MINT SVO value from known extras keys."""
5358
candidates = [
5459
"MINT_SVO",
5560
"MINT Standard Variables",
@@ -64,6 +69,7 @@ def _extract_mint_svo(extras: Iterable[Dict]) -> Optional[str]:
6469

6570

6671
def _resolve_mint_svo(resource: Dict, dataset: Dict) -> Optional[str]:
72+
"""Resolve the MINT SVO from resource or dataset metadata."""
6773
mint_svo = _extract_mint_svo(resource.get("extras", []))
6874
if mint_svo:
6975
return mint_svo
@@ -79,12 +85,14 @@ def _resolve_mint_svo(resource: Dict, dataset: Dict) -> Optional[str]:
7985

8086

8187
def get_tapis_token(username: str, password: str) -> str:
88+
"""Authenticate to Tapis and return the access token."""
8289
tapis = Tapis(base_url="https://portals.tapis.io", username=username, password=password)
8390
tapis.get_tokens()
8491
return tapis.access_token.access_token
8592

8693

8794
def get_jwt_token() -> str:
95+
"""Return a CKAN JWT from env or Tapis credentials."""
8896
jwt_token = os.environ.get("FLOPY_CKAN_JWT", "").strip()
8997
if jwt_token:
9098
return jwt_token
@@ -96,10 +104,12 @@ def get_jwt_token() -> str:
96104

97105

98106
def _headers(jwt_token: str) -> Dict[str, str]:
107+
"""Build auth headers for CKAN API requests."""
99108
return {"Authorization": f"Bearer {jwt_token}"}
100109

101110

102111
def package_show(jwt_token: str, dataset_name: str) -> Dict:
112+
"""Fetch CKAN dataset metadata by name."""
103113
url = f"{CKAN_URL}/api/3/action/package_show"
104114
response = requests.get(url, params={"id": dataset_name}, headers=_headers(jwt_token), timeout=60)
105115
response.raise_for_status()
@@ -110,6 +120,7 @@ def package_show(jwt_token: str, dataset_name: str) -> Dict:
110120

111121

112122
def create_dataset(jwt_token: str, dataset_dict: Dict) -> Dict:
123+
"""Create a new CKAN dataset."""
113124
url = f"{CKAN_URL}/api/3/action/package_create"
114125
response = requests.post(url, json=dataset_dict, headers=_headers(jwt_token), timeout=60)
115126
if response.status_code == 409:
@@ -127,6 +138,7 @@ def create_resource_upload(
127138
file_path: Path,
128139
resource_dict: Dict,
129140
) -> Dict:
141+
"""Upload a file as a CKAN resource."""
130142
url = f"{CKAN_URL}/api/3/action/resource_create"
131143
data = {
132144
"package_id": dataset_id,
@@ -156,6 +168,7 @@ def build_dataset_payload(
156168
change_summary: str | None = None,
157169
maintainer_username: str | None = None,
158170
) -> Dict:
171+
"""Create a dataset payload derived from a source dataset."""
159172
copy_fields = [
160173
"title",
161174
"notes",
@@ -206,6 +219,7 @@ def build_resource_payload(
206219
source_url: str | None = None,
207220
change_summary: str | None = None,
208221
) -> Dict:
222+
"""Create a resource payload derived from a source resource."""
209223
extras = source_resource.get("extras", [])
210224
extras = _merge_extras(
211225
extras,
@@ -243,6 +257,7 @@ def publish_updated_wel(
243257
change_summary: Optional[str] = None,
244258
maintainer_username: Optional[str] = None,
245259
) -> Dict:
260+
"""Create or update a derived dataset and upload the updated WEL file."""
246261
jwt_token = jwt_token or get_jwt_token()
247262
source_dataset = package_show(jwt_token, source_dataset_name)
248263
resources = source_dataset.get("resources", [])

dash/dash_app.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@
3030

3131
@lru_cache(maxsize=1)
3232
def get_datasets() -> List[Dict]:
33+
"""Fetch CKAN datasets cached for the app session."""
3334
return fwm._search_ckan_datasets()
3435

3536

3637
def _get_dataset_or_none(name: str | None) -> Dict | None:
38+
"""Return dataset metadata matching a name, if present."""
3739
if not name:
3840
return None
3941
datasets = get_datasets()
@@ -45,6 +47,7 @@ def _get_dataset_or_none(name: str | None) -> Dict | None:
4547

4648
@lru_cache(maxsize=8)
4749
def load_dataset(name: str) -> Dict:
50+
"""Download CKAN resources and assemble WEL/RCH/grid inputs."""
4851
dataset = _get_dataset_or_none(name)
4952
if not dataset:
5053
raise ValueError(f"Dataset not found: {name}")
@@ -89,6 +92,7 @@ def load_dataset(name: str) -> Dict:
8992
def _collect_wel_cells_for_periods(
9093
wel, gdf, periods: Sequence[int]
9194
) -> Dict[int, float]:
95+
"""Aggregate WEL flux by cell across selected periods."""
9296
cell_id_lookup = dict(zip(zip(gdf["ROW"], gdf["COL"]), gdf["CELL_ID"]))
9397
if not periods:
9498
spd = getattr(wel, "stress_period_data", None)
@@ -122,6 +126,7 @@ def _build_map_figure(
122126
force_linear: bool,
123127
selected_ids: Iterable[int],
124128
) -> go.Figure:
129+
"""Build the Plotly map figure with grid and selection overlays."""
125130
gdf_map = gdf[["CELL_ID", "ROW", "COL", "geometry"]].copy()
126131
grid_geojson = gdf_map.set_index("CELL_ID").__geo_interface__
127132
center_lat = float(gdf["_lat"].median())
@@ -246,6 +251,7 @@ def _build_map_figure(
246251

247252

248253
def _dataset_options() -> List[Dict[str, str]]:
254+
"""Build dataset dropdown options from CKAN search."""
249255
try:
250256
datasets = get_datasets()
251257
except Exception:
@@ -254,6 +260,7 @@ def _dataset_options() -> List[Dict[str, str]]:
254260

255261

256262
def _owned_gam_datasets(username: str, jwt_token: str) -> List[Dict[str, str]]:
263+
"""List datasets owned by a user for suggestion dropdowns."""
257264
if not username or not jwt_token:
258265
return []
259266
options: List[Dict[str, str]] = []
@@ -273,6 +280,7 @@ def _owned_gam_datasets(username: str, jwt_token: str) -> List[Dict[str, str]]:
273280
return options
274281

275282
def _slugify(value: str) -> str:
283+
"""Normalize a string for use in dataset naming."""
276284
value = value.strip().lower().replace(" ", "-")
277285
value = re.sub(r"[^a-z0-9\-_.]", "-", value)
278286
value = re.sub(r"-{2,}", "-", value)
@@ -483,6 +491,7 @@ def _slugify(value: str) -> str:
483491
Input("loaded-dataset", "data"),
484492
)
485493
def update_dataset_controls(loaded_dataset: str | None):
494+
"""Populate stress period and layer controls for the dataset."""
486495
if not loaded_dataset:
487496
return [], []
488497
data = load_dataset(loaded_dataset)
@@ -505,6 +514,7 @@ def update_dataset_controls(loaded_dataset: str | None):
505514
prevent_initial_call=False,
506515
)
507516
def load_selected_dataset(n_clicks, selected_dataset, load_counter):
517+
"""Load dataset resources and update load state messages."""
508518
if not selected_dataset:
509519
return None, "No dataset selected.", load_counter
510520
try:
@@ -528,6 +538,7 @@ def load_selected_dataset(n_clicks, selected_dataset, load_counter):
528538
def update_periods_layers(
529539
loaded_dataset, select_periods_clicks, select_layers_clicks, period_options, layer_options
530540
):
541+
"""Handle select-all interactions for periods and layers."""
531542
triggered = ctx.triggered_id
532543
if triggered == "select-all-periods":
533544
period_values = [opt["value"] for opt in (period_options or [])]
@@ -569,6 +580,7 @@ def update_periods_layers(
569580
prevent_initial_call=True,
570581
)
571582
def login_ckan(n_clicks, username, password):
583+
"""Authenticate with Tapis and store the CKAN JWT."""
572584
if not n_clicks:
573585
raise dash.exceptions.PreventUpdate
574586
if not username or not password:
@@ -589,6 +601,7 @@ def login_ckan(n_clicks, username, password):
589601
Input("loaded-dataset", "data"),
590602
)
591603
def update_category_controls(color_by, loaded_dataset):
604+
"""Show or hide category selection controls by color mode."""
592605
if not loaded_dataset or color_by == "flux":
593606
return [], None, {"display": "none"}, True
594607
data = load_dataset(loaded_dataset)
@@ -608,6 +621,7 @@ def update_category_controls(color_by, loaded_dataset):
608621
Input("ckan-jwt", "data"),
609622
)
610623
def update_dataset_suggestions(username, jwt_token):
624+
"""Return dataset suggestions owned by the logged-in user."""
611625
return _owned_gam_datasets(username or "", jwt_token or "")
612626

613627

@@ -653,6 +667,7 @@ def suggest_names(
653667
current_source_url,
654668
current_change_summary,
655669
):
670+
"""Generate dataset/output names and change summary from UI state."""
656671
if not loaded_dataset:
657672
return current_dataset_name, current_output_name, current_source_url, current_change_summary
658673
if suggested_name:
@@ -724,6 +739,7 @@ def update_selection(
724739
color_by,
725740
category_value,
726741
):
742+
"""Update selected cell IDs based on map interactions."""
727743
selected_ids = [int(cid) for cid in (selected_ids or [])]
728744
triggered = ctx.triggered_id
729745
if triggered == "clear-selection":
@@ -765,6 +781,7 @@ def update_selection(
765781
Input("selection-warning", "data"),
766782
)
767783
def update_selection_status(selected_ids, selection_warning):
784+
"""Update selection status text and warning styling."""
768785
count = len(selected_ids or [])
769786
class_name = "status"
770787
if selection_warning and count == 0:
@@ -778,6 +795,7 @@ def update_selection_status(selected_ids, selection_warning):
778795
Input("color-by", "value"),
779796
)
780797
def update_category_warning(category_warning, color_by):
798+
"""Highlight the category selector when a warning is active."""
781799
base = "category-wrap"
782800
if color_by != "flux" and category_warning:
783801
return f"{base} warn-pulse"
@@ -789,6 +807,7 @@ def update_category_warning(category_warning, color_by):
789807
Input("flux-source", "value"),
790808
)
791809
def update_output_label(flux_source):
810+
"""Switch the output label based on flux source."""
792811
return "Output RCH filename" if flux_source == "rch" else "Output WEL filename"
793812

794813

@@ -829,6 +848,7 @@ def apply_rate(
829848
change_summary,
830849
tapis_username,
831850
):
851+
"""Apply rate updates and optionally publish to CKAN."""
832852
if not n_clicks:
833853
return "", update_counter, False
834854
if not loaded_dataset:
@@ -924,6 +944,7 @@ def apply_rate(
924944
def update_map(
925945
loaded_dataset, _load_counter, flux_source, color_by, periods, selected_ids, _update
926946
):
947+
"""Refresh the map when dataset or settings change."""
927948
if not loaded_dataset:
928949
return go.Figure()
929950
data = load_dataset(loaded_dataset)

flopy_wel_map.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -802,11 +802,15 @@ def apply_rate_update(
802802
output_path: Path,
803803
) -> int:
804804
spd = wel.stress_period_data.data
805+
spd_dtype = getattr(wel.stress_period_data, "dtype", None)
805806
cell_lookup = dict(zip(gdf["CELL_ID"], zip(gdf["ROW"], gdf["COL"])))
806807
selected_cells = {cell_lookup[cid] for cid in selected_ids if cid in cell_lookup}
807808
new_spd = {}
808809
for per, recs in spd.items():
809-
recs = recs.copy()
810+
if spd_dtype is not None:
811+
recs = np.array(recs, dtype=spd_dtype)
812+
else:
813+
recs = np.array(recs)
810814
if periods_for_update and per not in periods_for_update:
811815
new_spd[per] = recs
812816
continue
@@ -837,6 +841,14 @@ def apply_rate_update(
837841
idx += 1
838842
recs = new_recs
839843
new_spd[per] = recs
844+
if spd_dtype is not None:
845+
cleaned = {}
846+
for per, recs in new_spd.items():
847+
if recs is None or len(recs) == 0:
848+
cleaned[per] = np.zeros(0, dtype=spd_dtype)
849+
else:
850+
cleaned[per] = np.array(recs, dtype=spd_dtype)
851+
new_spd = cleaned
840852
wel.stress_period_data = new_spd
841853
wel.write_file(str(output_path))
842854
return len(selected_cells)

0 commit comments

Comments
 (0)