"""Head-pump operating-curve auditing for hydraulic verification."""
from __future__ import annotations
from dataclasses import dataclass
import math
from typing import Hashable
import pandas as pd
from wntr.network import WaterNetworkModel
from wntr.utils.check_values import _check_positive_or_zero_float
from .exceptions import InvalidConstraintError
[docs]
@dataclass(frozen=True)
class PumpCurveResult:
"""Result of auditing one head pump against its operating curve."""
pump_name: str
curve_name: str | None
evaluable: bool
passed: bool
active_observations: int
exceedance_observations: int
curve_maximum_flow_m3s: float | None
maximum_active_flow_m3s: float | None
maximum_allowed_flow_m3s: float | None
maximum_flow_ratio: float | None
critical_time: Hashable | None
[docs]
@dataclass(frozen=True)
class AllPumpCurveResult:
"""Combined operating-curve result for every head pump."""
head_pumps_in_network: int
head_pumps_evaluable: int
all_head_pumps_evaluable: bool
all_pumps_passed: bool
number_of_pumps_exceeding_curves: int
total_curve_exceedance_observations: int
governing_pump_name: str | None
maximum_pump_flow_ratio: float | None
pump_results: tuple[PumpCurveResult, ...]
def _numeric_series(
table: pd.DataFrame | None,
column: str,
index: pd.Index,
) -> pd.Series | None:
if table is None or column not in table.columns:
return None
return pd.to_numeric(
table[column].reindex(index),
errors="coerce",
)
def _curve_maximum_flow(
pump: object,
) -> tuple[str | None, float | None]:
"""Return the maximum flow represented by a head-pump curve.
For a one-point curve, WNTR and EPANET generate an implied pump
curve. Its zero-head flow is calculated from the generated
A, B, and C coefficients.
For curves containing two or more supplied points, the maximum
supplied flow is treated as the explicit curve-domain boundary.
"""
curve_name = getattr(pump, "pump_curve_name", None)
try:
curve = pump.get_pump_curve()
except (AttributeError, KeyError, RuntimeError):
return curve_name, None
points = getattr(curve, "points", None)
if not points:
return curve_name, None
curve_name = str(getattr(curve, "name", curve_name))
valid_flows: list[float] = []
for point in points:
try:
flow = float(point[0])
except (TypeError, ValueError, IndexError):
continue
if math.isfinite(flow) and flow >= 0.0:
valid_flows.append(flow)
if not valid_flows:
return curve_name, None
if len(points) == 1:
try:
coefficient_a, coefficient_b, coefficient_c = (
float(value)
for value in pump.get_head_curve_coefficients()
)
except (
AttributeError,
RuntimeError,
TypeError,
ValueError,
ZeroDivisionError,
):
return curve_name, None
coefficients_valid = (
math.isfinite(coefficient_a)
and math.isfinite(coefficient_b)
and math.isfinite(coefficient_c)
and coefficient_a > 0.0
and coefficient_b > 0.0
and coefficient_c > 0.0
)
if not coefficients_valid:
return curve_name, None
try:
maximum_flow = (
coefficient_a / coefficient_b
) ** (1.0 / coefficient_c)
except (
ArithmeticError,
OverflowError,
ValueError,
):
return curve_name, None
if not math.isfinite(maximum_flow) or maximum_flow <= 0.0:
return curve_name, None
return curve_name, maximum_flow
return curve_name, max(valid_flows)
[docs]
def audit_head_pump_curves(
network: WaterNetworkModel,
flowrate: pd.DataFrame,
*,
status: pd.DataFrame | None = None,
setting: pd.DataFrame | None = None,
relative_tolerance: float = 1.0e-9,
) -> AllPumpCurveResult:
"""Audit every head pump against the maximum flow in its curve.
Pump affinity scaling is respected by multiplying the maximum
curve-point flow by the pump speed reported in the hydraulic
``setting`` result. When no setting result is supplied, the pump's
configured base speed is used.
Parameters
----------
network : WaterNetworkModel
WNTR water-network model used for the simulation.
flowrate : pd.DataFrame
Link-flowrate results indexed by simulation time.
status : pd.DataFrame or None, optional
Optional link-status results. Positive values are treated as active.
setting : pd.DataFrame or None, optional
Optional link-setting results. For pumps, this represents speed.
relative_tolerance : float, optional
Relative numerical tolerance applied to the curve-domain limit.
"""
if not isinstance(network, WaterNetworkModel):
raise TypeError("network must be a WaterNetworkModel")
if not isinstance(flowrate, pd.DataFrame):
raise TypeError("flowrate must be a pandas DataFrame")
tolerance = _check_positive_or_zero_float(
relative_tolerance,
"relative_tolerance",
)
if not math.isfinite(tolerance):
raise InvalidConstraintError(
"relative_tolerance must be finite."
)
pump_names = list(
getattr(network, "head_pump_name_list", [])
)
pump_results: list[PumpCurveResult] = []
for pump_name in pump_names:
pump = network.get_link(pump_name)
curve_name, curve_maximum_flow = _curve_maximum_flow(pump)
flow = _numeric_series(
flowrate,
pump_name,
flowrate.index,
)
if flow is None or curve_maximum_flow is None:
pump_results.append(
PumpCurveResult(
pump_name=str(pump_name),
curve_name=curve_name,
evaluable=False,
passed=False,
active_observations=0,
exceedance_observations=0,
curve_maximum_flow_m3s=curve_maximum_flow,
maximum_active_flow_m3s=None,
maximum_allowed_flow_m3s=None,
maximum_flow_ratio=None,
critical_time=None,
)
)
continue
pump_status = _numeric_series(
status,
pump_name,
flowrate.index,
)
if pump_status is None:
invalid_status = pd.Series(
False,
index=flowrate.index,
dtype=bool,
)
active = flow.notna() & flow.abs().gt(0.0)
else:
invalid_status = (
pump_status.isna()
| ~pump_status.map(math.isfinite)
| pump_status.lt(0.0)
)
active = pump_status.gt(0.0) & ~invalid_status
pump_setting = _numeric_series(
setting,
pump_name,
flowrate.index,
)
base_speed = float(getattr(pump, "base_speed", 1.0))
if not math.isfinite(base_speed) or base_speed < 0.0:
base_speed = 1.0
if pump_setting is None:
speed = pd.Series(
base_speed,
index=flowrate.index,
dtype=float,
)
else:
speed = pump_setting
allowed_flow = curve_maximum_flow * speed
active_observations = int(active.sum())
invalid_observation = invalid_status | (
active
& (
flow.isna()
| ~flow.map(math.isfinite)
| flow.lt(0.0)
| speed.isna()
| ~speed.map(math.isfinite)
| speed.le(0.0)
| allowed_flow.isna()
| ~allowed_flow.map(math.isfinite)
| allowed_flow.le(0.0)
)
)
if bool(invalid_observation.any()):
pump_results.append(
PumpCurveResult(
pump_name=str(pump_name),
curve_name=curve_name,
evaluable=False,
passed=False,
active_observations=active_observations,
exceedance_observations=0,
curve_maximum_flow_m3s=curve_maximum_flow,
maximum_active_flow_m3s=None,
maximum_allowed_flow_m3s=None,
maximum_flow_ratio=None,
critical_time=invalid_observation.idxmax(),
)
)
continue
valid = active
if active_observations == 0:
pump_results.append(
PumpCurveResult(
pump_name=str(pump_name),
curve_name=curve_name,
evaluable=True,
passed=True,
active_observations=0,
exceedance_observations=0,
curve_maximum_flow_m3s=curve_maximum_flow,
maximum_active_flow_m3s=0.0,
maximum_allowed_flow_m3s=None,
maximum_flow_ratio=0.0,
critical_time=None,
)
)
continue
active_flow = flow.loc[valid]
active_allowed = allowed_flow.loc[valid]
ratio = active_flow / active_allowed
exceeded = ratio.gt(1.0 + tolerance)
exceedance_observations = int(exceeded.sum())
critical_time = ratio.idxmax()
pump_results.append(
PumpCurveResult(
pump_name=str(pump_name),
curve_name=curve_name,
evaluable=True,
passed=exceedance_observations == 0,
active_observations=active_observations,
exceedance_observations=exceedance_observations,
curve_maximum_flow_m3s=curve_maximum_flow,
maximum_active_flow_m3s=float(
active_flow.loc[critical_time]
),
maximum_allowed_flow_m3s=float(
active_allowed.loc[critical_time]
),
maximum_flow_ratio=float(
ratio.loc[critical_time]
),
critical_time=critical_time,
)
)
head_pumps_in_network = len(pump_results)
head_pumps_evaluable = sum(
result.evaluable for result in pump_results
)
evaluable_results = [
result
for result in pump_results
if result.evaluable
]
ratio_results = [
result
for result in evaluable_results
if result.maximum_flow_ratio is not None
]
governing_result = (
max(
ratio_results,
key=lambda result: float(
result.maximum_flow_ratio
),
)
if ratio_results
else None
)
all_head_pumps_evaluable = (
head_pumps_evaluable == head_pumps_in_network
)
all_pumps_passed = (
all_head_pumps_evaluable
and all(result.passed for result in pump_results)
)
return AllPumpCurveResult(
head_pumps_in_network=head_pumps_in_network,
head_pumps_evaluable=head_pumps_evaluable,
all_head_pumps_evaluable=all_head_pumps_evaluable,
all_pumps_passed=all_pumps_passed,
number_of_pumps_exceeding_curves=sum(
not result.passed
for result in evaluable_results
),
total_curve_exceedance_observations=sum(
result.exceedance_observations
for result in evaluable_results
),
governing_pump_name=(
governing_result.pump_name
if governing_result is not None
else None
),
maximum_pump_flow_ratio=(
governing_result.maximum_flow_ratio
if governing_result is not None
else None
),
pump_results=tuple(pump_results),
)