Skip to content

Commit 012b2f1

Browse files
committed
feat: add DeltaCD UI Manager and integrate with command line interface
1 parent 485967e commit 012b2f1

4 files changed

Lines changed: 335 additions & 6 deletions

File tree

pydelmod/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ def dsm2_analyze(config_file):
306306
from pydelmod import schismcalibplotui
307307
from pydelmod import ptm_animator
308308
from pydelmod.nbplot import calsim_dsm2_analysis
309+
from pydelmod import deltacduimgr
309310

310311
main.add_command(schismui.show_schism_output_ui)
311312
main.add_command(schismcalibplotui.schism_calib_plot_ui)
@@ -328,6 +329,7 @@ def dsm2_analyze(config_file):
328329
main.add_command(channel_orient.generate_channel_orientation)
329330
main.add_command(ptm_animator.ptm_animate)
330331
main.add_command(dsm2_analyze)
332+
main.add_command(deltacduimgr.show_deltacd_ui)
331333

332334
if __name__ == "__main__":
333335
sys.exit(main()) # pragma: no cover

pydelmod/deltacduimgr.py

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import pandas as pd
2+
import xarray as xr
3+
import geopandas as gpd
4+
import numpy as np
5+
import holoviews as hv
6+
from pydelmod.dvue import tsdataui
7+
8+
class DeltaCDUIManager(tsdataui.TimeSeriesDataUIManager):
9+
"""
10+
UI Manager for DeltaCD netCDF data files.
11+
Handles data catalog creation and time series extraction for area_id and crop combinations.
12+
"""
13+
14+
def __init__(self, nc_file_path, geojson_file_path=None):
15+
"""
16+
Initialize the DeltaCD UI Manager.
17+
18+
Parameters:
19+
-----------
20+
nc_file_path : str
21+
Path to the netCDF file containing DeltaCD data
22+
geojson_file_path : str, optional
23+
Path to the GeoJSON file containing geographical information for area_ids
24+
"""
25+
self.nc_file_path = nc_file_path
26+
self.geojson_file_path = geojson_file_path
27+
self.ds = xr.open_dataset(nc_file_path)
28+
self.gdf = None
29+
30+
if geojson_file_path:
31+
self.gdf = gpd.read_file(geojson_file_path)
32+
self.gdf.rename(columns={"OBJECTID": "area_id"}, inplace=True)
33+
34+
# Initialize data cache
35+
self.data_cache = {}
36+
37+
# Set up columns for visualization
38+
self.color_cycle_column = "crop"
39+
self.dashed_line_cycle_column = "variable"
40+
self.marker_cycle_column = "area_id"
41+
42+
super().__init__(filename_column="source")
43+
44+
def get_data_catalog(self):
45+
"""
46+
Create a data catalog from the netCDF file.
47+
Each row represents a time series for a variable for an area_id and crop combination.
48+
Includes all possible combinations of area_id, crop, and variable.
49+
"""
50+
# Get available variables, excluding coordinates
51+
variables = [var for var in self.ds.data_vars]
52+
area_ids = self.ds.area_id.values
53+
crops = self.ds.crop.values
54+
55+
# Create all combinations using pandas products
56+
combinations = []
57+
for area_id in area_ids:
58+
for crop in crops:
59+
for var in variables:
60+
combinations.append({
61+
'area_id': int(area_id),
62+
'crop': str(crop),
63+
'variable': var
64+
})
65+
66+
# Create base DataFrame with all combinations
67+
df = pd.DataFrame(combinations)
68+
69+
# Get time range and other metadata for each combination
70+
variable_units = {var: self.ds[var].attrs.get("units", "") for var in variables}
71+
72+
# Add additional columns
73+
df['unit'] = df['variable'].map(variable_units)
74+
df['interval'] = 'daily' # Assuming all data is daily, adjust as needed
75+
df['source'] = self.nc_file_path
76+
77+
# Add time range information
78+
times = pd.to_datetime(self.ds.time.values)
79+
df['start_year'] = str(times.min().year)
80+
df['max_year'] = str(times.max().year)
81+
82+
# If geojson is available, convert to GeoDataFrame
83+
if self.gdf is not None:
84+
# Merge with geometry information based on area_id
85+
merged_df = pd.merge(df, self.gdf, on="area_id", how="left")
86+
87+
# Create GeoDataFrame
88+
catalog = gpd.GeoDataFrame(merged_df, geometry="geometry")
89+
90+
# Handle CRS properly
91+
# Check if the GeoDataFrame already has a CRS
92+
if self.gdf.crs is not None:
93+
# GDF already has a CRS, no need to set it
94+
pass
95+
else:
96+
# Set a default CRS if none exists
97+
catalog.set_crs(epsg=4326, inplace=True)
98+
99+
return catalog
100+
else:
101+
return df
102+
103+
def get_time_range(self, dfcat):
104+
"""Return the min and max time from the dataset"""
105+
times = pd.to_datetime(self.ds.time.values)
106+
return times.min(), times.max()
107+
108+
def build_station_name(self, r):
109+
"""Build a display name for the area_id and crop combination"""
110+
return f"Area {r['area_id']} - {r['crop']}"
111+
112+
def get_table_column_width_map(self):
113+
"""Define column widths for the data catalog table"""
114+
column_width_map = {
115+
"area_id": "8%",
116+
"crop": "15%",
117+
"variable": "12%",
118+
"unit": "8%",
119+
"interval": "10%",
120+
"start_year": "10%",
121+
"max_year": "10%",
122+
}
123+
return column_width_map
124+
125+
def get_table_filters(self):
126+
"""Define filters for the data catalog table"""
127+
table_filters = {
128+
"area_id": {
129+
"type": "input",
130+
"func": "like",
131+
"placeholder": "Enter area ID",
132+
},
133+
"crop": {
134+
"type": "input",
135+
"func": "like",
136+
"placeholder": "Enter crop type",
137+
},
138+
"variable": {
139+
"type": "input",
140+
"func": "like",
141+
"placeholder": "Enter variable",
142+
},
143+
"unit": {
144+
"type": "input",
145+
"func": "like",
146+
"placeholder": "Enter unit",
147+
},
148+
"interval": {
149+
"type": "input",
150+
"func": "like",
151+
"placeholder": "Enter interval",
152+
},
153+
"start_year": {
154+
"type": "input",
155+
"func": "like",
156+
"placeholder": "Enter start year",
157+
},
158+
"max_year": {
159+
"type": "input",
160+
"func": "like",
161+
"placeholder": "Enter end year",
162+
},
163+
}
164+
return table_filters
165+
166+
def is_irregular(self, r):
167+
"""Check if time series is irregular"""
168+
return False # Assuming all time series are regular
169+
170+
def get_data_for_time_range(self, r, time_range):
171+
"""
172+
Extract time series data for a specific area_id, crop, and variable combination
173+
within the specified time range.
174+
175+
Parameters:
176+
-----------
177+
r : pandas.Series
178+
Row from data catalog containing area_id, crop, and variable
179+
time_range : tuple
180+
Start and end time for data extraction
181+
182+
Returns:
183+
--------
184+
tuple
185+
(time series DataFrame, unit, data type)
186+
"""
187+
area_id = r["area_id"]
188+
crop = r["crop"]
189+
variable = r["variable"]
190+
unit = r["unit"]
191+
192+
# Extract data from xarray for the specific area_id, crop, and variable
193+
data = self.ds[variable].sel(area_id=area_id, crop=crop)
194+
195+
# Convert to pandas Series and then DataFrame
196+
df = data.to_pandas().to_frame()
197+
198+
# Filter by time range if specified
199+
if time_range and len(time_range) == 2:
200+
start_time, end_time = time_range
201+
df = df.loc[start_time:end_time]
202+
203+
return df, unit, "instantaneous"
204+
205+
def get_tooltips(self):
206+
"""Define tooltips for map visualization"""
207+
return [
208+
("Area ID", "@area_id"),
209+
("Crop", "@crop"),
210+
("Variable", "@variable"),
211+
("Unit", "@unit")
212+
]
213+
214+
def get_map_color_columns(self):
215+
"""Return columns that can be used to color the map"""
216+
return ["crop", "variable"]
217+
218+
def get_map_marker_columns(self):
219+
"""Return columns that can be used as markers on the map"""
220+
return ["variable", "crop"]
221+
222+
def create_curve(self, df, r, unit, file_index=None):
223+
"""Create a holoviews curve for plotting"""
224+
file_index_label = f"{file_index}:" if file_index is not None else ""
225+
crvlabel = f'{file_index_label}Area {r["area_id"]} - {r["crop"]}: {r["variable"]}'
226+
ylabel = f'{r["variable"]} ({unit})'
227+
title = f'{r["variable"]} for {r["crop"]} @ Area {r["area_id"]}'
228+
229+
crv = hv.Curve(df.iloc[:, [0]], label=crvlabel).redim(value=crvlabel)
230+
return crv.opts(
231+
xlabel="Time",
232+
ylabel=ylabel,
233+
title=title,
234+
responsive=True,
235+
active_tools=["wheel_zoom"],
236+
tools=["hover"]
237+
)
238+
239+
def _append_value(self, new_value, value):
240+
"""Helper method for title creation"""
241+
if new_value not in value:
242+
value += f'{", " if value else ""}{new_value}'
243+
return value
244+
245+
def append_to_title_map(self, title_map, unit, r):
246+
"""Append information to the title map for plot titles"""
247+
if unit in title_map:
248+
value = title_map[unit]
249+
else:
250+
value = ["", ""]
251+
value[0] = self._append_value(r["variable"], value[0])
252+
value[1] = self._append_value(f'Area {r["area_id"]} - {r["crop"]}', value[1])
253+
title_map[unit] = value
254+
255+
def create_title(self, v):
256+
"""Create plot title from values"""
257+
title = f"{v[1]} ({v[0]})"
258+
return title
259+
260+
import click
261+
@click.command()
262+
@click.argument("detaw_output_file", type=click.Path(exists=True, dir_okay=False))
263+
@click.option(
264+
"--geojson_file_path",
265+
type=click.Path(exists=True, dir_okay=False),
266+
default=None,
267+
help="Path to the GeoJSON file containing area geometries",
268+
)
269+
def show_deltacd_ui(detaw_output_file, geojson_file_path=None):
270+
"""
271+
Show the DeltaCD UI Manager for the specified netCDF file and GeoJSON file.
272+
"""
273+
dcd_ui = DeltaCDUIManager(detaw_output_file, geojson_file_path=geojson_file_path)
274+
from pydelmod.dvue import dataui
275+
import cartopy.crs as ccrs
276+
dui=dataui.DataUI(dcd_ui, station_id_column="area_id", crs=ccrs.epsg(26910))
277+
dui.create_view().servable(title="DeltaCD UI Manager").show()

pydelmod/dvue/dataui.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,8 @@ def _get_map_catalog(self):
245245
dfx = dfx.dropna(subset=["geometry"])
246246
dfx = dfx.set_crs(self._dfcat.crs)
247247
else:
248-
dfx = dfx.dropna(subset=["Latitude", "Longitude"]) # FIXME: ?
248+
pass
249+
#dfx = dfx.dropna(subset=["Latitude", "Longitude"]) # FIXME: ?
249250
else:
250251
dfx = self._dfcat
251252
return dfx
@@ -258,15 +259,15 @@ def build_map_of_features(self, dfmap, crs):
258259
# check if the dfmap is a geodataframe
259260
try:
260261
if isinstance(dfmap, gpd.GeoDataFrame):
261-
geom_type = dfmap.geometry.iloc[0].geom_type
262-
if geom_type == "Point":
262+
geom_type = str.lower(str(dfmap.geometry.iloc[0].geom_type))
263+
if "point" in geom_type:
263264
self._map_features = gv.Points(dfmap, crs=crs)
264-
elif geom_type == "LineString":
265+
elif "line" in geom_type:
265266
self._map_features = gv.Path(dfmap, crs=crs)
266-
elif geom_type == "Polygon":
267+
elif "polygon" in geom_type:
267268
self._map_features = gv.Polygons(dfmap, crs=crs)
268269
else: # pragma: no cover
269-
raise "Unknown geometry type " + geom_type
270+
raise ValueError("Unknown geometry type " + geom_type)
270271
except Exception as e:
271272
logger.error(f"Error building map of features: {e}")
272273
self._map_features = gv.Points(dfmap, crs=crs)

tests/ex_deltacdui.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#%%
2+
import geoviews as gv
3+
import geopandas as gpd
4+
from bokeh.plotting import show
5+
import holoviews as hv
6+
import cartopy.crs as ccrs
7+
# Initialize renderers
8+
gv.extension('bokeh')
9+
10+
# Load the GeoJSON file
11+
file_path = '../examples/dsm2gis/detaw168subareas.geojson'
12+
gdf = gpd.read_file(file_path)
13+
14+
# Create a GeoViews polygon feature
15+
geom = gv.Polygons(gdf, crs=ccrs.epsg(26910)).opts(responsive=True,
16+
tools=['hover'],)
17+
18+
# Display the GeoJSON data with a proper background
19+
tiles = gv.tile_sources.OSM()
20+
map_view = tiles * geom.opts(line_width=1, fill_alpha=0.5, tools=['hover'])
21+
22+
# Show the result
23+
import panel as pn
24+
pn.extension()
25+
pn.panel(pn.Row(map_view, sizing_mode="stretch_both")).servable(title="GeoJSON Map with Background Tiles").show()
26+
# %%
27+
detaw_output_file = "d:/delta/deltacd_inputs/historical/outputs/detawoutput_dsm2.nc"
28+
import xarray as xr
29+
# %%
30+
ds = xr.open_dataset(detaw_output_file)
31+
# %%
32+
ds
33+
# %%
34+
from pydelmod.deltacduimgr import DeltaCDUIManager
35+
detaw_output_file = "d:/delta/deltacd_inputs/historical/outputs/detawoutput_dsm2.nc"
36+
file_path = '../pydelmod/dsm2gis/detaw168subareas.geojson'
37+
#%%
38+
#dcd_ui = DeltaCDUIManager(detaw_output_file, geojson_file_path=file_path)
39+
dcd_ui = DeltaCDUIManager(detaw_output_file)
40+
dfcat=dcd_ui.get_data_catalog()
41+
# %%
42+
from pydelmod.dvue import dataui
43+
import cartopy.crs as ccrs
44+
dui=dataui.DataUI(dcd_ui, station_id_column="area_id", crs=ccrs.epsg(26910))
45+
dui.create_view().servable(title="DeltaCD UI Manager").show()
46+
47+
# %%
48+
dcd_ui.get_data_for_time_range(dfcat.iloc[0],dcd_ui.get_time_range(dfcat))
49+
# %%

0 commit comments

Comments
 (0)