Skip to content

Commit 4732dd0

Browse files
committed
feat: add DeltaCD Nodes UI command to display UI manager for netCDF files
1 parent 39fedbe commit 4732dd0

2 files changed

Lines changed: 325 additions & 0 deletions

File tree

pydelmod/cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ def dsm2_analyze(config_file):
325325
main.add_command(stations_output_file)
326326
main.add_command(dsm2gis.geolocate_output_locations)
327327
main.add_command(deltacdui.dcd_geomap)
328+
main.add_command(deltacdui.show_deltacd_nodes_ui)
328329
main.add_command(calibplotui.calib_plot_ui)
329330
main.add_command(channel_orient.generate_channel_orientation)
330331
main.add_command(ptm_animator.ptm_animate)

pydelmod/deltacdui.py

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,319 @@
44
import geopandas as gpd
55
from functools import partial
66
import click
7+
import logging
8+
9+
# Configure logger for this module
10+
logger = logging.getLogger(__name__)
11+
logger.setLevel(logging.INFO)
12+
logger.addHandler(logging.StreamHandler())
713

814
# viz imports
915
import geoviews as gv
1016
import hvplot.pandas
1117
import hvplot.xarray
1218
import holoviews as hv
1319
from holoviews import opts, dim
20+
import cartopy.crs as ccrs
1421

1522
hv.extension("bokeh")
1623
#
1724
import panel as pn
1825

1926
pn.extension()
2027

28+
from pydelmod.dvue import tsdataui
29+
30+
class DeltaCDNodesUIManager(tsdataui.TimeSeriesDataUIManager):
31+
"""
32+
UI Manager for DeltaCD netCDF data files with node as the station dimension.
33+
Handles data catalog creation and time series extraction for node and variable combinations.
34+
"""
35+
36+
def __init__(self, *nc_file_paths, **kwargs):
37+
"""
38+
Initialize the DeltaCD Nodes UI Manager.
39+
40+
Parameters:
41+
-----------
42+
nc_file_paths : str or list of str
43+
Paths to the netCDF files containing DeltaCD data. Can be a single path or multiple paths.
44+
nodes_file : str, optional
45+
Path to the GeoJSON file containing geographical information for nodes
46+
"""
47+
self.nc_file_paths = nc_file_paths
48+
self.nodes_file_path = kwargs.pop("nodes_file", None)
49+
self.datasets = {}
50+
dfcats = []
51+
for nc_file_path in self.nc_file_paths:
52+
if not nc_file_path.endswith('.nc'):
53+
raise ValueError(f"Invalid file type: {nc_file_path}. Expected a netCDF file (.nc).")
54+
self.datasets[nc_file_path] = xr.open_dataset(nc_file_path)
55+
dfcat = self.get_data_catalog_for_dataset(self.datasets[nc_file_path], nc_file_path)
56+
dfcats.append(dfcat)
57+
self.gdf = None
58+
if self.nodes_file_path:
59+
self.gdf = gpd.read_file(self.nodes_file_path)
60+
# Make sure the node ID column is named 'id' to match with data
61+
if 'id' not in self.gdf.columns:
62+
raise ValueError(f"GeoJSON file must contain an 'id' column for node IDs. Available columns: {self.gdf.columns}")
63+
64+
# concatenate all data catalogs
65+
dfcat = pd.concat(dfcats, ignore_index=True)
66+
67+
# Merge with GeoDataFrame if available
68+
if self.gdf is not None:
69+
# Convert node to string in both DataFrames before merging
70+
dfcat['node'] = dfcat['node'].astype(str)
71+
gdf_copy = self.gdf.copy()
72+
gdf_copy['id'] = gdf_copy['id'].astype(str)
73+
74+
# Merge with geometry information based on node id
75+
merged_df = pd.merge(dfcat, gdf_copy, left_on="node", right_on="id", how="left")
76+
77+
# Create GeoDataFrame
78+
catalog = gpd.GeoDataFrame(merged_df, geometry="geometry")
79+
80+
# Handle CRS properly
81+
if self.gdf.crs is not None:
82+
catalog.crs = self.gdf.crs
83+
else:
84+
catalog.set_crs(epsg=26910, inplace=True)
85+
else:
86+
# If no GeoDataFrame, just use the DataFrame
87+
catalog = dfcat
88+
89+
self.dfcat = catalog
90+
# Initialize data cache
91+
self.data_cache = {}
92+
93+
kwargs['filename_column'] = "source"
94+
super().__init__(**kwargs)
95+
# Set up columns for visualization
96+
self.color_cycle_column = "node"
97+
self.dashed_line_cycle_column = "variable"
98+
self.marker_cycle_column = "node"
99+
100+
def get_data_catalog(self):
101+
return self.dfcat
102+
103+
def get_data_catalog_for_dataset(self, ds, nc_file_path):
104+
"""
105+
Create a data catalog from the netCDF file.
106+
Each row represents a time series for a variable for a node combination.
107+
Includes all possible combinations of node and variable.
108+
"""
109+
# Get available variables, excluding coordinates
110+
variables = list(ds.data_vars)
111+
dims = list(ds.dims)
112+
if "time" not in dims:
113+
raise ValueError(f"Dataset must contain a 'time' dimension. Dimensions available: {dims}")
114+
if "node" not in dims:
115+
raise ValueError(f"Dataset must contain a 'node' dimension. Dimensions available: {dims}")
116+
nodes = ds.node.values
117+
118+
# Create all combinations
119+
combinations = []
120+
for node in nodes:
121+
for var in variables:
122+
combinations.append({
123+
'node': node,
124+
'variable': var
125+
})
126+
127+
# Create base DataFrame with all combinations
128+
df = pd.DataFrame(combinations)
129+
130+
# Add additional columns
131+
# Try to get units from variables
132+
variable_units = {}
133+
for var in variables:
134+
try:
135+
# Try to get unit from the variable's attributes
136+
unit = ds[var].attrs.get("units", "")
137+
variable_units[var] = unit
138+
logger.debug(f"Found unit '{unit}' for variable '{var}'")
139+
except Exception as e:
140+
logger.debug(f"Error getting unit for {var}: {e}")
141+
variable_units[var] = "" # Default to empty string
142+
143+
df['unit'] = df['variable'].map(variable_units)
144+
df['interval'] = 'daily' # Assuming all data is daily
145+
df['source'] = nc_file_path
146+
147+
# Add time range information
148+
times = pd.to_datetime(ds.time.values)
149+
df['start_year'] = str(times.min().year)
150+
df['max_year'] = str(times.max().year)
151+
return df
152+
153+
def get_time_range(self, dfcat):
154+
"""Return the min and max time from the dataset"""
155+
starttime = pd.to_datetime(dfcat['start_year'].min())
156+
endtime = pd.to_datetime(dfcat['max_year'].max())
157+
return starttime, endtime
158+
159+
def build_station_name(self, r):
160+
"""Build a display name for the node"""
161+
return f"Node {r['node']}"
162+
163+
def _get_table_column_width_map(self):
164+
"""Define column widths for the data catalog table"""
165+
return {
166+
"node": "8%",
167+
"variable": "12%",
168+
"unit": "8%",
169+
"interval": "10%",
170+
"start_year": "10%",
171+
"max_year": "10%",
172+
}
173+
174+
def get_table_filters(self):
175+
"""Define filters for the data catalog table"""
176+
return {
177+
"node": {
178+
"type": "input",
179+
"func": "like",
180+
"placeholder": "Enter node ID",
181+
},
182+
"variable": {
183+
"type": "input",
184+
"func": "like",
185+
"placeholder": "Enter variable",
186+
},
187+
"unit": {
188+
"type": "input",
189+
"func": "like",
190+
"placeholder": "Enter unit",
191+
},
192+
"interval": {
193+
"type": "input",
194+
"func": "like",
195+
"placeholder": "Enter interval",
196+
},
197+
"start_year": {
198+
"type": "input",
199+
"func": "like",
200+
"placeholder": "Enter start year",
201+
},
202+
"max_year": {
203+
"type": "input",
204+
"func": "like",
205+
"placeholder": "Enter end year",
206+
},
207+
}
208+
209+
def is_irregular(self, r):
210+
"""Check if time series is irregular"""
211+
return False # Assuming all time series are regular
212+
213+
def get_data_for_time_range(self, r, time_range):
214+
"""
215+
Extract time series data for a specific node and variable combination
216+
within the specified time range.
217+
218+
Parameters:
219+
-----------
220+
r : pandas.Series
221+
Row from data catalog containing node and variable
222+
time_range : tuple
223+
Start and end time for data extraction
224+
225+
Returns:
226+
--------
227+
tuple
228+
(time series DataFrame, unit, data type)
229+
"""
230+
node = r["node"]
231+
variable = r["variable"]
232+
unit = r["unit"]
233+
filename = r["source"]
234+
ds = self.datasets[filename]
235+
try:
236+
# Extract data from xarray for the specific node and variable
237+
data = ds[variable].sel(node=node)
238+
239+
# Convert to pandas Series and then DataFrame
240+
df = data.to_pandas().to_frame()
241+
# Ensure the index is a datetime index
242+
if not isinstance(df.index, pd.DatetimeIndex):
243+
# Try to convert the index to a datetime index
244+
df.index = pd.to_datetime(df.index)
245+
246+
# Filter by time range if specified
247+
if time_range and len(time_range) == 2:
248+
start_time, end_time = time_range
249+
df = df.loc[start_time:end_time]
250+
251+
return df, unit, "instantaneous"
252+
253+
except Exception as e:
254+
# Handle any exception that occurs during data extraction
255+
logger.error(f"Error extracting data for node={node}, variable={variable}: {e}")
256+
return pd.DataFrame(), unit, "instantaneous"
257+
258+
def get_tooltips(self):
259+
"""Define tooltips for map visualization"""
260+
return [
261+
("Node ID", "@node"),
262+
("Variable", "@variable"),
263+
("Unit", "@unit")
264+
]
265+
266+
def get_map_color_columns(self):
267+
"""Return columns that can be used to color the map"""
268+
return ["variable"]
269+
270+
def get_map_marker_columns(self):
271+
"""Return columns that can be used as markers on the map"""
272+
return ["variable"]
273+
274+
def create_curve(self, df, r, unit, file_index=None):
275+
"""Create a holoviews curve for plotting"""
276+
file_index_label = f"{file_index}:" if file_index is not None else ""
277+
278+
crvlabel = f'{file_index_label}Node {r["node"]}: {r["variable"]}'
279+
title = f'{r["variable"]} @ Node {r["node"]}'
280+
281+
ylabel = f'{r["variable"]} ({unit})'
282+
283+
# Create curve with appropriate data
284+
if df.empty:
285+
crv = hv.Curve(pd.DataFrame({'x': [], 'y': []}), kdims=['x'], vdims=['y'], label=crvlabel).redim(y=crvlabel)
286+
else:
287+
crv = hv.Curve(df, label=crvlabel).redim(value=crvlabel)
288+
289+
return crv.opts(
290+
xlabel="Time",
291+
ylabel=ylabel,
292+
title=title,
293+
responsive=True,
294+
active_tools=["wheel_zoom"],
295+
tools=["hover"]
296+
)
297+
298+
def _append_value(self, new_value, value):
299+
"""Helper method for title creation"""
300+
if new_value not in value:
301+
value += f'{", " if value else ""}{new_value}'
302+
return value
303+
304+
def append_to_title_map(self, title_map, unit, r):
305+
"""Append information to the title map for plot titles"""
306+
if unit in title_map:
307+
value = title_map[unit]
308+
else:
309+
value = ["", ""]
310+
value[0] = self._append_value(r["variable"], value[0])
311+
location_str = f'Node {r["node"]}'
312+
value[1] = self._append_value(location_str, value[1])
313+
title_map[unit] = value
314+
315+
def create_title(self, v):
316+
"""Create plot title from values"""
317+
title = f"{v[1]} ({v[0]})"
318+
return title
319+
21320

22321
def build_map(time, gdf, df=None, var=""):
23322
if var == "diversion":
@@ -46,6 +345,31 @@ def build_map(time, gdf, df=None, var=""):
46345
)
47346

48347

348+
@click.command()
349+
@click.argument(
350+
"nc_files",
351+
nargs=-1,
352+
type=click.Path(exists=True, dir_okay=False),
353+
required=True,
354+
)
355+
@click.option(
356+
"--nodes_file",
357+
type=click.Path(exists=True, dir_okay=False),
358+
default=None,
359+
help="Path to the GeoJSON file containing node geometries",
360+
)
361+
def show_deltacd_nodes_ui(nc_files, nodes_file=None):
362+
"""
363+
Show the DeltaCD Nodes UI Manager for the specified netCDF file(s) and nodes GeoJSON file.
364+
365+
This UI is designed for netCDF files that use 'node' as the station dimension.
366+
"""
367+
dcd_ui = DeltaCDNodesUIManager(*nc_files, nodes_file=nodes_file)
368+
from pydelmod.dvue import dataui
369+
dui = dataui.DataUI(dcd_ui, station_id_column="node", crs=ccrs.epsg(26910))
370+
dui.create_view().servable(title="DeltaCD Nodes UI Manager").show()
371+
372+
49373
@click.command()
50374
@click.option(
51375
"--ncfile",

0 commit comments

Comments
 (0)