|
| 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}") |
0 commit comments