Skip to content

Commit 2b625a0

Browse files
committed
SDG-266: add hydro volume calculation utility and CLI command
1 parent c3a0740 commit 2b625a0

6 files changed

Lines changed: 328 additions & 1 deletion

File tree

pydsm/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pydsm.input.dcd_calcs import calc_netcd_cmd
1111
from pydsm.input import extend_dss_ts
1212
from pydsm.output.create_gtm_restart import write_gtm_restart
13+
from pydsm.output.hydro_vol_calcs import calc_volumes_cmd
1314
from pydsm.input import channel_orient
1415

1516

@@ -366,5 +367,6 @@ def create_gtm_restart_cmd(tidefile, target_time, outfile, constituent):
366367
main.add_command(extend_dss_ts.extend_dss_ts)
367368
main.add_command(channel_orient.generate_channel_orientation, "chan-orient")
368369
main.add_command(calc_netcd_cmd)
370+
main.add_command(calc_volumes_cmd)
369371
if __name__ == "__main__":
370372
sys.exit(main())

pydsm/input/dcd_calcs.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import click
2+
import sys
23
import pandas as pd
34
import pyhecdss as dss
5+
from pydsm.output.utils import write_csv_with_meta
46

57

68
def get_bpart_pattern():
@@ -165,5 +167,12 @@ def calc_netcd_cmd(dssfile, bpart, bpart_file, no_seepage, epart, output):
165167
)
166168

167169
netcd = calculate_netcd(div_flows, drain_flows, seep_flows)
168-
netcd.to_csv(output, header=["NETCD"])
170+
meta = {
171+
"command": " ".join(sys.argv),
172+
"dssfile": dssfile,
173+
"bparts": ", ".join(bparts) if bparts else "default (all numeric + BBID)",
174+
"epart": epart,
175+
"seepage": "excluded" if no_seepage else ("included" if seep_flows is not None else "not found (excluded)"),
176+
}
177+
write_csv_with_meta(output, netcd, meta, header=["NETCD"])
169178
click.echo(f"NetCD written to {output}")

pydsm/output/hydro_vol_calcs.py

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
"""
2+
Utility functions and CLI command for calculating DSM2 Hydro volumes.
3+
4+
Channel volume = channel average cross-sectional area (ft²) × channel length (ft)
5+
Reservoir volume = reservoir area (million ft²) × 1e6 × reservoir water height (ft)
6+
7+
Unit conversion factors (relative to cubic feet):
8+
cubic-feet : 1.0
9+
acre-feet : 1 / 43560
10+
maf : 1 / (43560 × 1 000 000)
11+
"""
12+
13+
import click
14+
import sys
15+
import pandas as pd
16+
17+
from pydsm.output.hydroh5 import HydroH5
18+
from pydsm.output.utils import write_csv_with_meta
19+
20+
# ---------------------------------------------------------------------------
21+
# Unit conversion
22+
# ---------------------------------------------------------------------------
23+
24+
UNITS = {
25+
"cubic-feet": 1.0,
26+
"acre-feet": 1.0 / 43560.0,
27+
"maf": 1.0 / (43560.0 * 1e6),
28+
}
29+
30+
UNIT_LABELS = {
31+
"cubic-feet": "ft³",
32+
"acre-feet": "acre-ft",
33+
"maf": "MAF",
34+
}
35+
36+
37+
def convert_volume(vol_cubic_feet, unit):
38+
"""
39+
Convert a volume Series or DataFrame from cubic feet to the requested unit.
40+
41+
Parameters
42+
----------
43+
vol_cubic_feet : pandas.Series or pandas.DataFrame
44+
unit : str
45+
One of 'cubic-feet', 'acre-feet', 'maf'.
46+
47+
Returns
48+
-------
49+
Same type as input, scaled to the requested unit.
50+
"""
51+
factor = UNITS[unit]
52+
return vol_cubic_feet * factor
53+
54+
55+
# ---------------------------------------------------------------------------
56+
# Core calculation functions
57+
# ---------------------------------------------------------------------------
58+
59+
def get_channel_volumes(hydro, channels=None, timewindow=None, unit="acre-feet"):
60+
"""
61+
Calculate per-channel volumes as time series.
62+
63+
Volume = channel average area (ft²) × channel length (ft), converted to *unit*.
64+
65+
Parameters
66+
----------
67+
hydro : HydroH5
68+
Open HydroH5 instance.
69+
channels : list of str | None
70+
Channel numbers to include. None or empty list means all channels.
71+
timewindow : str | None
72+
DSM2 style time window e.g. '01JAN2014 - 01JAN2015'.
73+
unit : str
74+
Output unit: 'cubic-feet', 'acre-feet', or 'maf'.
75+
76+
Returns
77+
-------
78+
pandas.DataFrame
79+
Time-indexed DataFrame with one column per channel. Column names are
80+
channel numbers (str). Values are in *unit*.
81+
"""
82+
chan_table = hydro.get_channels() # chan_no already cast to str
83+
chan_lengths = chan_table.set_index("chan_no")["length"].astype(float)
84+
85+
if not channels:
86+
channels = list(chan_lengths.index.astype(str))
87+
88+
channels = [str(c) for c in channels]
89+
chan_lengths = chan_lengths.loc[channels]
90+
91+
avg_areas = hydro.get_channel_avg_area(channels, timewindow)
92+
# avg_areas columns are channel numbers as strings
93+
avg_areas.columns = [c.split("-")[0] for c in avg_areas.columns]
94+
95+
vol_cft = avg_areas.multiply(chan_lengths.values, axis=1)
96+
return convert_volume(vol_cft, unit)
97+
98+
99+
def get_reservoir_volumes(hydro, reservoirs=None, timewindow=None, unit="acre-feet"):
100+
"""
101+
Calculate per-reservoir volumes as time series.
102+
103+
Volume = reservoir area (million ft²) × 1e6 × reservoir height (ft), converted to *unit*.
104+
105+
Parameters
106+
----------
107+
hydro : HydroH5
108+
Open HydroH5 instance.
109+
reservoirs : list of str | None
110+
Reservoir names to include. None or empty list means all reservoirs.
111+
timewindow : str | None
112+
DSM2 style time window e.g. '01JAN2014 - 01JAN2015'.
113+
unit : str
114+
Output unit: 'cubic-feet', 'acre-feet', or 'maf'.
115+
116+
Returns
117+
-------
118+
pandas.DataFrame
119+
Time-indexed DataFrame with one column per reservoir. Values are in *unit*.
120+
"""
121+
res_table = hydro.get_input_table("/hydro/input/reservoir")
122+
# areas stored in millions of square feet
123+
res_areas = res_table.set_index("name")["area"].astype(float) * 1e6
124+
125+
if not reservoirs:
126+
reservoirs = list(res_areas.index.astype(str))
127+
128+
reservoirs = [str(r) for r in reservoirs]
129+
res_areas = res_areas.loc[reservoirs]
130+
131+
heights = hydro.get_reservoir_height(reservoirs, timewindow)
132+
heights.columns = [c for c in reservoirs]
133+
134+
vol_cft = heights.multiply(res_areas.values, axis=1)
135+
return convert_volume(vol_cft, unit)
136+
137+
138+
# ---------------------------------------------------------------------------
139+
# CLI
140+
# ---------------------------------------------------------------------------
141+
142+
@click.command(name="calc-volumes")
143+
@click.argument("hydrofile", type=click.Path(exists=True))
144+
@click.option(
145+
"--timewindow",
146+
default=None,
147+
help='Time window, e.g. "01JAN2014 - 01JAN2015" (quoted on command line)',
148+
)
149+
@click.option(
150+
"--channel",
151+
multiple=True,
152+
help="Channel number to include (repeatable). Defaults to all channels.",
153+
)
154+
@click.option(
155+
"--channel-file",
156+
type=click.Path(exists=True),
157+
default=None,
158+
help="Text file with one channel number per line.",
159+
)
160+
@click.option(
161+
"--reservoir",
162+
multiple=True,
163+
help="Reservoir name to include (repeatable). Defaults to all reservoirs.",
164+
)
165+
@click.option(
166+
"--reservoir-file",
167+
type=click.Path(exists=True),
168+
default=None,
169+
help="Text file with one reservoir name per line.",
170+
)
171+
@click.option(
172+
"--unit",
173+
default="acre-feet",
174+
show_default=True,
175+
type=click.Choice(["cubic-feet", "acre-feet", "maf"], case_sensitive=False),
176+
help="Volume unit for output.",
177+
)
178+
@click.option(
179+
"--no-channels",
180+
is_flag=True,
181+
default=False,
182+
help="Skip channel volume calculation.",
183+
)
184+
@click.option(
185+
"--no-reservoirs",
186+
is_flag=True,
187+
default=False,
188+
help="Skip reservoir volume calculation.",
189+
)
190+
@click.option(
191+
"-o",
192+
"--output",
193+
default="volumes.csv",
194+
show_default=True,
195+
help="Output CSV file path.",
196+
)
197+
def calc_volumes_cmd(
198+
hydrofile, timewindow, channel, channel_file, reservoir, reservoir_file, unit, no_channels, no_reservoirs, output
199+
):
200+
"""Calculate DSM2 channel and/or reservoir volumes from a Hydro HDF5 tidefile.
201+
202+
Volume is reported as a time series summed over the selected channels and
203+
reservoirs. Use --channel / --reservoir to restrict to specific items;
204+
omitting them includes everything in the file.
205+
206+
\b
207+
Examples
208+
--------
209+
# All channels + reservoirs, monthly window, acre-feet (default unit)
210+
pydsm calc-volumes hist.h5 --timewindow "01JAN2014 - 01JAN2015"
211+
212+
# Channel subset, MAF
213+
pydsm calc-volumes hist.h5 --channel 1 --channel 2 --unit maf -o chan_vols.csv
214+
215+
# Reservoirs only
216+
pydsm calc-volumes hist.h5 --no-channels --unit maf -o res_vols.csv
217+
"""
218+
unit = unit.lower()
219+
label = UNIT_LABELS[unit]
220+
221+
chan_list = list(channel)
222+
if channel_file:
223+
with open(channel_file) as f:
224+
chan_list.extend(line.strip() for line in f if line.strip())
225+
226+
res_list = list(reservoir)
227+
if reservoir_file:
228+
with open(reservoir_file) as f:
229+
res_list.extend(line.strip() for line in f if line.strip())
230+
231+
hydro = HydroH5(hydrofile)
232+
233+
result_parts = {}
234+
235+
if not no_channels:
236+
try:
237+
chan_vols = get_channel_volumes(hydro, chan_list or None, timewindow, unit)
238+
result_parts["channel_volume"] = chan_vols.sum(axis=1)
239+
click.echo(
240+
f"Channels: {len(chan_vols.columns)} included, "
241+
f"mean total = {result_parts['channel_volume'].mean():.4g} {label}"
242+
)
243+
except Exception as e:
244+
raise click.ClickException(f"Failed to calculate channel volumes: {e}")
245+
246+
if not no_reservoirs:
247+
try:
248+
res_vols = get_reservoir_volumes(hydro, res_list or None, timewindow, unit)
249+
result_parts["reservoir_volume"] = res_vols.sum(axis=1)
250+
click.echo(
251+
f"Reservoirs: {len(res_vols.columns)} included, "
252+
f"mean total = {result_parts['reservoir_volume'].mean():.4g} {label}"
253+
)
254+
except Exception as e:
255+
raise click.ClickException(f"Failed to calculate reservoir volumes: {e}")
256+
257+
if not result_parts:
258+
raise click.ClickException("Nothing to calculate: both --no-channels and --no-reservoirs were set.")
259+
260+
df_out = pd.DataFrame(result_parts)
261+
df_out["total_volume"] = df_out.sum(axis=1)
262+
df_out.index.name = "datetime"
263+
264+
meta = {
265+
"command": " ".join(sys.argv),
266+
"hydrofile": hydrofile,
267+
"unit": label,
268+
"timewindow": timewindow or "all",
269+
"channels": ", ".join(chan_list) if chan_list else "all",
270+
"reservoirs": ", ".join(res_list) if res_list else ("none" if no_reservoirs else "all"),
271+
}
272+
write_csv_with_meta(output, df_out, meta)
273+
click.echo(f"Volumes ({label}) written to {output}")

pydsm/output/utils.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,31 @@
11
from pydsm.output import hydroh5
22
from pydsm.input import read_input, write_input
33
import h5py
4+
import sys
5+
6+
7+
def write_csv_with_meta(filepath, df, meta, header=None):
8+
"""
9+
Write a DataFrame to CSV with leading comment lines containing metadata.
10+
11+
Comment lines are prefixed with ``#`` so they can be skipped when reading
12+
back with ``pd.read_csv(..., comment='#')``.
13+
14+
Parameters
15+
----------
16+
filepath : str
17+
Destination CSV path.
18+
df : pandas.DataFrame or pandas.Series
19+
Data to write.
20+
meta : dict
21+
Ordered key/value pairs written as ``# key: value`` lines.
22+
header : list of str | None
23+
Column header override passed to ``to_csv``.
24+
"""
25+
with open(filepath, "w") as f:
26+
for key, value in meta.items():
27+
f.write(f"# {key}: {value}\n")
28+
df.to_csv(f, header=header if header is not None else True)
429

530
def update_hydro_tidefile_with_inp(hydro_tidefile, input_file, tidefile_path='/hydro/input/channel', table_name='CHANNEL'):
631
"""
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
125
2+
126
3+
127
4+
128
5+
129
6+
130
7+
131
8+
132
9+
133

tests/data/middle_river_nodes.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
104
2+
105
3+
106
4+
107
5+
108
6+
109
7+
110
8+
111
9+
112

0 commit comments

Comments
 (0)