Commit 9c15f811 authored by Turner, Sean's avatar Turner, Sean
Browse files

nonpower release constraints

parent 7d925a1b
Loading
Loading
Loading
Loading
+18 −5
Original line number Diff line number Diff line
@@ -94,6 +94,8 @@ class ReservoirData:
    tailwater_elevation: float
    max_release: list[float]
    min_release: list[float]
    min_non_power_release: list[float] = field(default_factory=list)
    max_non_power_release: list[float] = field(default_factory=list)
    set_storage: list[float]
    set_elevation: list[float]
    catchment_inflow: list[float]
@@ -122,8 +124,12 @@ cascade_data = CascadeData(
            initial_pool_elevation=200.0,
            min_power_pool=[190.0] * 24,
            tailwater_elevation=150.0,
            max_release=[2.0] * 24,    # Mm3/hr
            min_release=[0.0] * 24,    # Mm3/hr
            max_release=[2.0] * 24,    # total controlled outflow, Mm3/hr
            min_release=[0.0] * 24,    # total controlled outflow, Mm3/hr
            # optional fishway / bypass path; omit or use None / np.nan / "NA"
            # for unconstrained behavior
            min_non_power_release=[],
            max_non_power_release=[],
            # storage–elevation (SET) curve
            set_storage=[0.0, 250.0, 500.0],
            set_elevation=[180.0, 195.0, 210.0],
@@ -147,6 +153,7 @@ results = powersheds.simulate_cascade(cascade_data)

res = results["DemoReservoir"]
print("release (Mm3/hr)", res["release"][:5])
print("non-power release (Mm3/hr)", res["non_power_release"][:5])
print("storage (Mm3)",    res["storage"][:5])
print("power (MW)",       res["actual_power"][:5])
```
@@ -172,8 +179,10 @@ Reservoir
- `initial_pool_elevation` (m)
- `min_power_pool`: list[float] length T (m)
- `tailwater_elevation` (m)
- `max_release`: list[float] length T (Mm3/hr)
- `min_release`: list[float] length T (Mm3/hr)
- `max_release`: list[float] length T, total controlled outflow max (turbine + non-power, Mm3/hr)
- `min_release`: list[float] length T, total controlled outflow min (turbine + non-power, Mm3/hr)
- `min_non_power_release`: optional list[float] length T for fishway / bypass release minimum, or omit / use `None` / `np.nan` / `"NA"`
- `max_non_power_release`: optional list[float] length T for fishway / bypass release maximum, or omit / use `None` / `np.nan` / `"NA"`
- `max_operating_elevation`: optional list[float] length T, or empty
- `set_storage` and `set_elevation`: arrays for the storage–elevation curve
- `catchment_inflow`: list[float] length T
@@ -206,7 +215,11 @@ powersheds.simulate_cascade(cascade_data) -> dict[str, Result]

Returned per-object results:

- Reservoir: `storage`, `pool_elevation`, `head`, `release`, `spill`, `catchment_inflow`, `total_inflow`, `target_release`, `target_power`, `actual_power`, `tailwater_elevation`, `violation_min_power_pool`, `violation_min_release`, `violation_max_release`, `violation_max_operating_elevation`
- Reservoir: `storage`, `pool_elevation`, `head`, `release`, `non_power_release`, `total_controlled_outflow`, `total_outflow`, `spill`, `catchment_inflow`, `total_inflow`, `target_release`, `target_power`, `actual_power`, `tailwater_elevation`, `violation_min_power_pool`, `violation_min_release`, `violation_max_release`, `violation_min_non_power_release`, `violation_max_non_power_release`, `violation_max_operating_elevation`

Notes:
- Input `min_release` / `max_release` constrain total controlled outflow, not passive spill.
- Output `release` and `target_release` remain turbine-only series.
- River: `inflow`, `outflow` (with travel-time lag)
- Confluence: `inflow`, `outflow` (no lag)

+354 −1
Original line number Diff line number Diff line
from powersheds._lib import *
from __future__ import annotations

import math
from collections.abc import Mapping
from types import SimpleNamespace

import pandas as pd

from . import _lib

__all__ = ["simulate_cascade", "compute_release", "compute_power"]

compute_release = _lib.compute_release
compute_power = _lib.compute_power

_MISSING = object()
_NA_STRINGS = {"na", "nan"}


def _get_field(obj, name, default=_MISSING):
    if isinstance(obj, Mapping):
        return obj.get(name, default)
    return getattr(obj, name, default)


def _as_list(value):
    if isinstance(value, (list, tuple)):
        return list(value)
    if hasattr(value, "tolist") and not isinstance(value, (str, bytes, bytearray)):
        converted = value.tolist()
        if isinstance(converted, list):
            return converted
        if isinstance(converted, tuple):
            return list(converted)
        return [converted]
    return None


def _is_missing_scalar(value) -> bool:
    if value is _MISSING or value is None:
        return True
    if isinstance(value, str):
        return value.strip().lower() in _NA_STRINGS
    try:
        return bool(pd.isna(value))
    except (TypeError, ValueError):
        return False


def _coerce_float(value, *, reservoir_name: str, field_name: str, allow_missing: bool) -> float:
    if _is_missing_scalar(value):
        if allow_missing:
            return math.nan
        raise ValueError(
            f"Reservoir '{reservoir_name}': {field_name} cannot contain missing values"
        )

    try:
        return float(value)
    except (TypeError, ValueError) as exc:
        raise ValueError(
            f"Reservoir '{reservoir_name}': {field_name} must contain numeric values"
        ) from exc


def _normalize_optional_series(obj, field_name: str, length: int, *, reservoir_name: str) -> list[float]:
    raw = _get_field(obj, field_name, _MISSING)
    if raw is _MISSING or _is_missing_scalar(raw):
        return [math.nan] * length

    values = _as_list(raw)
    if values is None:
        return [_coerce_float(raw, reservoir_name=reservoir_name, field_name=field_name, allow_missing=True)] * length
    if len(values) == 0:
        return [math.nan] * length
    if len(values) != length:
        raise ValueError(
            f"Reservoir '{reservoir_name}': {field_name} must have length {length}"
        )
    return [
        _coerce_float(
            value,
            reservoir_name=reservoir_name,
            field_name=field_name,
            allow_missing=True,
        )
        for value in values
    ]


def _normalize_required_series(
    obj,
    field_name: str,
    *,
    reservoir_name: str,
    allow_scalar: bool = True,
    allow_missing: bool = False,
) -> list[float]:
    raw = _get_field(obj, field_name, _MISSING)
    if raw is _MISSING:
        raise ValueError(f"Reservoir '{reservoir_name}': missing required field {field_name}")

    values = _as_list(raw)
    if values is None:
        if not allow_scalar:
            raise ValueError(
                f"Reservoir '{reservoir_name}': {field_name} must be a sequence"
            )
        return [
            _coerce_float(
                raw,
                reservoir_name=reservoir_name,
                field_name=field_name,
                allow_missing=allow_missing,
            )
        ]

    if len(values) == 0:
        raise ValueError(
            f"Reservoir '{reservoir_name}': {field_name} must be non-empty"
        )

    return [
        _coerce_float(
            value,
            reservoir_name=reservoir_name,
            field_name=field_name,
            allow_missing=allow_missing,
        )
        for value in values
    ]


def _normalize_required_time_series(
    obj,
    field_name: str,
    length: int,
    *,
    reservoir_name: str,
) -> list[float]:
    raw = _get_field(obj, field_name, _MISSING)
    if raw is _MISSING:
        raise ValueError(f"Reservoir '{reservoir_name}': missing required field {field_name}")

    values = _as_list(raw)
    if values is None:
        return [
            _coerce_float(
                raw,
                reservoir_name=reservoir_name,
                field_name=field_name,
                allow_missing=False,
            )
        ] * length

    if len(values) != length:
        raise ValueError(
            f"Reservoir '{reservoir_name}': {field_name} must have length {length}"
        )

    return [
        _coerce_float(
            value,
            reservoir_name=reservoir_name,
            field_name=field_name,
            allow_missing=False,
        )
        for value in values
    ]


def _normalize_string_field(obj, field_name: str, *, reservoir_name: str) -> str:
    raw = _get_field(obj, field_name, _MISSING)
    if raw is _MISSING:
        raise ValueError(f"Reservoir '{reservoir_name}': missing required field {field_name}")
    return str(raw)


def _normalize_required_float_field(obj, field_name: str, *, reservoir_name: str) -> float:
    raw = _get_field(obj, field_name, _MISSING)
    if raw is _MISSING:
        raise ValueError(f"Reservoir '{reservoir_name}': missing required field {field_name}")
    return _coerce_float(
        raw,
        reservoir_name=reservoir_name,
        field_name=field_name,
        allow_missing=False,
    )


def _normalize_optional_vector(obj, field_name: str, *, reservoir_name: str) -> list[float]:
    raw = _get_field(obj, field_name, _MISSING)
    if raw is _MISSING or _is_missing_scalar(raw):
        return []

    values = _as_list(raw)
    if values is None:
        raise ValueError(
            f"Reservoir '{reservoir_name}': {field_name} must be a sequence"
        )
    if len(values) == 0:
        return []

    return [
        _coerce_float(
            value,
            reservoir_name=reservoir_name,
            field_name=field_name,
            allow_missing=False,
        )
        for value in values
    ]


def _normalize_reservoir(name: str, reservoir) -> SimpleNamespace:
    catchment_inflow = _normalize_required_series(
        reservoir,
        "catchment_inflow",
        reservoir_name=name,
        allow_scalar=False,
    )
    series_lengths = [len(catchment_inflow)]
    for field_name in (
        "target_power",
        "min_power_pool",
        "max_release",
        "min_release",
        "min_non_power_release",
        "max_non_power_release",
        "max_operating_elevation",
    ):
        raw = _get_field(reservoir, field_name, _MISSING)
        values = _as_list(raw)
        if values:
            series_lengths.append(len(values))
    n = max(series_lengths)

    target_power = _normalize_required_time_series(
        reservoir,
        "target_power",
        n,
        reservoir_name=name,
    )

    hpf_h = _normalize_required_series(
        reservoir, "hpf_h", reservoir_name=name, allow_scalar=False, allow_missing=True
    )
    hpf_p = _normalize_required_series(
        reservoir, "hpf_p", reservoir_name=name, allow_scalar=False, allow_missing=True
    )
    hpf_q = _normalize_required_series(
        reservoir, "hpf_q", reservoir_name=name, allow_scalar=False, allow_missing=True
    )

    return SimpleNamespace(
        object_type=_normalize_string_field(reservoir, "object_type", reservoir_name=name),
        capacity=_normalize_required_float_field(reservoir, "capacity", reservoir_name=name),
        initial_pool_elevation=_normalize_required_float_field(
            reservoir, "initial_pool_elevation", reservoir_name=name
        ),
        min_power_pool=_normalize_optional_series(
            reservoir, "min_power_pool", n, reservoir_name=name
        ),
        set_storage=_normalize_required_series(
            reservoir, "set_storage", reservoir_name=name, allow_scalar=False
        ),
        set_elevation=_normalize_required_series(
            reservoir, "set_elevation", reservoir_name=name, allow_scalar=False
        ),
        tailwater_elevation=float(_get_field(reservoir, "tailwater_elevation")),
        max_release=_normalize_optional_series(
            reservoir, "max_release", n, reservoir_name=name
        ),
        min_release=_normalize_optional_series(
            reservoir, "min_release", n, reservoir_name=name
        ),
        max_non_power_release=_normalize_optional_series(
            reservoir, "max_non_power_release", n, reservoir_name=name
        ),
        min_non_power_release=_normalize_optional_series(
            reservoir, "min_non_power_release", n, reservoir_name=name
        ),
        catchment_inflow=catchment_inflow,
        target_power=target_power,
        simulation_order=int(_normalize_required_float_field(reservoir, "simulation_order", reservoir_name=name)),
        downstream_object=_normalize_string_field(
            reservoir, "downstream_object", reservoir_name=name
        ),
        hpf_h=hpf_h,
        hpf_p=hpf_p,
        hpf_q=hpf_q,
        set_tw_outflow=_normalize_optional_vector(
            reservoir, "set_tw_outflow", reservoir_name=name
        ),
        set_tw_elevation=_normalize_optional_vector(
            reservoir, "set_tw_elevation", reservoir_name=name
        ),
        max_operating_elevation=_normalize_optional_series(
            reservoir, "max_operating_elevation", n, reservoir_name=name
        ),
    )


def _normalize_river(name: str, river) -> SimpleNamespace:
    legacy_flows = _as_list(_get_field(river, "legacy_flows", [])) or []
    return SimpleNamespace(
        object_type=str(_get_field(river, "object_type")),
        simulation_order=int(_get_field(river, "simulation_order")),
        downstream_object=str(_get_field(river, "downstream_object")),
        lag=int(_get_field(river, "lag")),
        legacy_flows=[float(value) for value in legacy_flows],
    )


def _normalize_confluence(name: str, confluence) -> SimpleNamespace:
    return SimpleNamespace(
        object_type=str(_get_field(confluence, "object_type")),
        simulation_order=int(_get_field(confluence, "simulation_order")),
        downstream_object=str(_get_field(confluence, "downstream_object")),
    )


def _normalize_section(cascade_data, section_name: str):
    section = _get_field(cascade_data, section_name, {})
    if section is _MISSING or section is None:
        return {}
    if not isinstance(section, Mapping):
        raise ValueError(f"cascade_data.{section_name} must be a mapping")
    return dict(section)


def _normalize_cascade_data(cascade_data) -> SimpleNamespace:
    reservoirs = {
        name: _normalize_reservoir(name, reservoir)
        for name, reservoir in _normalize_section(cascade_data, "reservoirs").items()
    }
    rivers = {
        name: _normalize_river(name, river)
        for name, river in _normalize_section(cascade_data, "rivers").items()
    }
    confluences = {
        name: _normalize_confluence(name, confluence)
        for name, confluence in _normalize_section(cascade_data, "confluences").items()
    }
    return SimpleNamespace(
        reservoirs=reservoirs,
        rivers=rivers,
        confluences=confluences,
    )


def simulate_cascade(cascade_data):
    normalized = _normalize_cascade_data(cascade_data)
    return _lib.simulate_cascade(normalized)
+143 −33

File changed.

Preview size limit exceeded, changes collapsed.

+355 −5

File changed.

Preview size limit exceeded, changes collapsed.

+2 −0
Original line number Diff line number Diff line
@@ -32,6 +32,8 @@ class ReservoirData:
    target_power: list
    simulation_order: int
    downstream_object: str
    min_non_power_release: list = field(default_factory=list)
    max_non_power_release: list = field(default_factory=list)
    set_tw_outflow: list = field(default_factory=list)
    set_tw_elevation: list = field(default_factory=list)
    max_operating_elevation: list = field(default_factory=list)
Loading