'''
High-level plotting helpers for hydraulic Q-H analysis.
This module extends the lower-level plotting utilities by providing
domain-focused plotting workflows for pump/circuit interaction diagrams,
especially Q-H (flow-head) charts.
Main responsibilities:
* collect pumps, circuits, and operating points in a unified plot context,
* generate sampled Q-H curves and overlay system/working points,
* provide plotting controls for labels, ranges, and slider-driven interaction,
* bridge hydraulic model objects with matplotlib-based presentation.
Design intent:
* keep engineering plotting logic close to hydraulic concepts,
* reduce repetitive plotting boilerplate in examples and notebooks,
* allow quick visual comparison of pump curves vs circuit demand curves.
Typical usage::
plotter = PlotQHcurve(
pumps=[pump],
circuits=[circuit],
wpoints=[wp],
npts=80,
)
plotter.show()
For generic figure/canvas mechanics, this module builds on ``plotlib`` and
adds hydraulic-specific composition rules on top.
'''
# =============================================================================
# PYLINT DIRECTIVES
# =============================================================================
# =============================================================================
# IMPORTS
# =============================================================================
from typing import Any, Optional
import numpy as np
import matplotlib.pyplot as mplt
# module own
import fluidsolve.aux_tools as flsa
import fluidsolve.medium as flsme
import fluidsolve.plotlib as flsp
# units
u = flsme.unitRegistry
Quantity = flsme.Quantity # type: ignore[misc]
# =============================================================================
# PLOT SIMPLE CLASS
# =============================================================================
[docs]
class PlotSimple:
''' Plot user-provided x/y data on a single graph.
This class does not calculate hydraulic curves. It only renders data that is
passed directly to the class.
Args:
x (list, optional): X values.
y (list, optional): Y values.
type (str, optional): Curve type: ``line``, ``scatter``, or ``bar``.
label (str, optional): Curve label.
color (str, optional): Curve color.
alpha (float, optional): Curve alpha.
linestyle (str, optional): Curve linestyle.
marker (str, optional): Curve marker.
xlabel (str, optional): X-axis label.
ylabel (str, optional): Y-axis label.
xmin (int | float, optional): X-axis minimum.
xmax (int | float, optional): X-axis maximum.
xstep (int | float, optional): X-axis major tick step.
ymin (int | float, optional): Y-axis minimum.
ymax (int | float, optional): Y-axis maximum.
ystep (int | float, optional): Y-axis major tick step.
'''
[docs]
def __init__(self, **kwargs: int) -> None:
args = flsa.GetArgs(kwargs)
self._x: list = args.getArg(
'x',
[
flsa.vFun.default([]),
flsa.vFun.istype(list),
]
)
self._y: list = args.getArg(
'y',
[
flsa.vFun.default([]),
flsa.vFun.istype(list),
]
)
self._type: str = args.getArg(
'type',
[
flsa.vFun.default('line'),
flsa.vFun.istype(str),
flsa.vFun.inlist('line', 'scatter', 'bar'),
]
)
self._label: str = args.getArg(
'label',
[
flsa.vFun.default(None),
flsa.vFun.istype(str, need=False),
]
)
self._color: str = args.getArg(
'color',
[
flsa.vFun.default(None),
flsa.vFun.istype(str, need=False),
]
)
self._alpha: float = args.getArg(
'alpha',
[
flsa.vFun.default(None),
flsa.vFun.istype(float, need=False),
]
)
self._linestyle: str = args.getArg(
'linestyle',
[
flsa.vFun.default(None),
flsa.vFun.istype(str, need=False),
]
)
self._marker: str = args.getArg(
'marker',
[
flsa.vFun.default(None),
flsa.vFun.istype(str, need=False),
]
)
xmin: int | float = args.getArg(
'xmin',
[
flsa.vFun.default(None),
flsa.vFun.istype(float, int, need=False),
]
)
xmax: int | float = args.getArg(
'xmax',
[
flsa.vFun.default(None),
flsa.vFun.istype(float, int, need=False),
]
)
xstep: int | float = args.getArg(
'xstep',
[
flsa.vFun.default(None),
flsa.vFun.istype(float, int, need=False),
]
)
xlabel: str = args.getArg(
'xlabel',
[
flsa.vFun.default('x'),
flsa.vFun.istype(str),
]
)
ymin: int | float = args.getArg(
'ymin',
[
flsa.vFun.default(None),
flsa.vFun.istype(float, int, need=False),
]
)
ymax: int | float = args.getArg(
'ymax',
[
flsa.vFun.default(None),
flsa.vFun.istype(float, int, need=False),
]
)
ystep: int | float = args.getArg(
'ystep',
[
flsa.vFun.default(None),
flsa.vFun.istype(float, int, need=False),
]
)
ylabel: str = args.getArg(
'ylabel',
[
flsa.vFun.default('y'),
flsa.vFun.istype(str),
]
)
# generate objects
self._fig: flsp.PlotFigure = flsp.PlotFigure(**args.restArgs())
self._graph: flsp.PlotGraph = flsp.PlotGraph(self._fig, r=0, c=0)
xaxis_args = flsa.prepareArgs(
vmin = xmin,
vmax = xmax,
vstep = xstep,
labeltxt = xlabel,
)
yaxis_args = flsa.prepareArgs(
vmin = ymin,
vmax = ymax,
vstep = ystep,
labeltxt = ylabel,
)
self._graph.setXAxis(**xaxis_args)
self._graph.setYAxis(**yaxis_args)
self._graph.setGrid(axis='both')
self._curve: Optional[flsp.PlotCurve] = None
self._prepare: bool = True
[docs]
def setData(self, x: list, y: list) -> None:
''' Replace the current data series. '''
self._x = x
self._y = y
if self._curve is not None:
self._curve.x = self._x
self._curve.y = self._y
[docs]
def prepareShow(self) -> None:
''' Build plot objects and assign initial data. '''
if self._prepare:
self._curve = flsp.PlotCurve(
self._graph,
type=self._type,
x=self._x,
y=self._y,
label=self._label,
color=self._color,
alpha=self._alpha,
linestyle=self._linestyle,
marker=self._marker,
)
self._fig.prepareShow()
self._prepare = False
[docs]
def show(self) -> None:
''' Prepare and display the plot. '''
self.prepareShow()
self._fig.show()
[docs]
def update(self) -> None:
''' Redraw the full figure with current data. '''
if self._curve is not None:
self._curve.x = self._x
self._curve.y = self._y
self._fig.update()
[docs]
def updateData(self) -> None:
''' Update only curve data without full redraw logic. '''
if self._curve is not None:
self._curve.x = self._x
self._curve.y = self._y
self._fig.updateData()
# =============================================================================
# PLOT Q-H CURVE CLASS
# =============================================================================
[docs]
class PlotQHcurve:
''' Plot a Q-H diagram with pump curves, circuit curves, and working points.
Args:
pumps (object | list): Pump or list of pumps.
circuits (object | list): Circuit or list of circuits.
wpoints (object | list, optional): Working points to mark.
spoints (object | list, optional): System (static) points to mark.
npts (int, optional): Number of curve sample points.
Qmax (int | float | Quantity, optional): Maximum flow on Q axis.
Hmax (int | float | Quantity, optional): Maximum head on H axis.
xlabel (str, optional): X-axis label.
ylabel (str, optional): Y-axis label.
sliders (list, optional): Slider widget definitions.
xmin (int | float, optional): X-axis minimum override.
xmax (int | float, optional): X-axis maximum override.
xstep (int | float, optional): X-axis major tick step override.
ymin (int | float, optional): Y-axis minimum override.
ymax (int | float, optional): Y-axis maximum override.
ystep (int | float, optional): Y-axis major tick step override.
'''
[docs]
def __init__(self, **kwargs: int) -> None:
args = flsa.GetArgs(kwargs)
self._pumps: list = args.getArg(
'pumps',
[
flsa.vFun.default([]),
flsa.vFun.istype(object, list, tuple),
]
)
self._circuits: list = args.getArg(
'circuits',
[
flsa.vFun.default([]),
flsa.vFun.istype(object, list, tuple),
]
)
self._wpoints: list = args.getArg(
'wpoints',
[
flsa.vFun.default([]),
flsa.vFun.istype(object, list, tuple),
]
)
self._spoints: list = args.getArg(
'spoints',
[
flsa.vFun.default([]),
flsa.vFun.istype(object, list, tuple),
]
)
self._npts: int = args.getArg(
'npts',
[
flsa.vFun.default(50),
flsa.vFun.istype(int, float),
flsa.vFun.totype(int),
]
)
self._Qmax: float = args.getArg(
'Qmax',
[
flsa.vFun.default(50),
flsa.vFun.istype(int, float, Quantity),
flsa.vFun.tounits(u.m**3/u.h, magnitude=True),
]
)
self._Hmax: float = args.getArg(
'Hmax',
[
flsa.vFun.default(15),
flsa.vFun.istype(int, float, Quantity),
flsa.vFun.tounits(u.m, magnitude=True),
]
)
xmin: int | float = args.getArg(
'xmin',
[
flsa.vFun.default(None),
flsa.vFun.istype(float, int, need=False),
]
)
xmax: int | float = args.getArg(
'xmax',
[
flsa.vFun.default(None),
flsa.vFun.istype(float, int, need=False),
]
)
xstep: int | float = args.getArg(
'xstep',
[
flsa.vFun.default(None),
flsa.vFun.istype(float, int, need=False),
]
)
xlabel: str = args.getArg(
'xlabel',
[
flsa.vFun.default('Q (m³/h)'),
flsa.vFun.istype(str),
]
)
ymin: int | float = args.getArg(
'ymin',
[
flsa.vFun.default(None),
flsa.vFun.istype(float, int, need=False),
]
)
ymax: int | float = args.getArg(
'ymax',
[
flsa.vFun.default(None),
flsa.vFun.istype(float, int, need=False),
]
)
ystep: int | float = args.getArg(
'ystep',
[
flsa.vFun.default(None),
flsa.vFun.istype(float, int, need=False),
]
)
ylabel: str = args.getArg(
'ylabel',
[
flsa.vFun.default('H (m)'),
flsa.vFun.istype(str),
]
)
sliders: list = args.getArg(
'sliders',
[
flsa.vFun.default([]),
flsa.vFun.istype(list, need=False),
]
)
self._extra: dict = {}
# generate objects
self._fig : flsp.PlotFigure = flsp.PlotFigure(**args.restArgs())
# specific configuration
self._graph : flsp.PlotGraph = flsp.PlotGraph(self._fig, r=0, c=0)
xaxis_args = flsa.prepareArgs(
vmin = xmin,
vmax = xmax,
vstep = xstep,
labeltxt = xlabel,
)# | self._extra['xaxis']
yaxis_args = flsa.prepareArgs(
vmin = ymin,
vmax = ymax,
vstep = ystep,
labeltxt = ylabel,
)# | self._extra['yaxis']
self._graph.setXAxis(**xaxis_args)
self._graph.setYAxis(**yaxis_args)
self._graph.setGrid(axis='both')
# widgets
self._buttonreset : Optional[flsp.PlotButton] = None
self._sliders : list = []
if len(sliders) > 0:
self._fig.hw = (len(sliders) + 1) * 30
self._fig.nrw = len(sliders) + 1
self._fig.ncw = 10
# button to reset the sliders to initial values
buttonreset_pars = {
'r': 0, 'c': 8,
'label': 'Reset',
'fun': self._resetControls,
'color': 'lightblue', 'hovercolor': 'yellow',
}
self._buttonreset = flsp.PlotButton(self._fig, **buttonreset_pars)
for i, slider in enumerate(sliders):
slider_pars = flsa.prepareArgs(r=i+1, c='0:9') | slider
self._sliders.append(flsp.PlotSlider(self._fig, **slider_pars))
# local data
self._curvepumps : list = []
self._curvecircuits : list = []
self._curvewpts : list = []
self._curvespts : list = []
self._annotationwpts : list = []
self._annotationspts : list = []
#
self._prepare : bool = True
[docs]
def update(self) -> Any:
''' Recalculate and redraw the full figure. '''
self._calcAndUpdate()
self._fig.update()
[docs]
def updateData(self) -> Any:
''' Recalculate and update only the plot data without a full redraw. '''
self._calcAndUpdate()
self._fig.updateData()
[docs]
def prepareShow(self) -> None:
''' Build plot objects and calculate initial data. '''
if self._prepare:
for _pump in self._pumps:
self._curvepumps.append(flsp.PlotCurve(self._graph, type='line', extra={'zorder': 1}))
for _circuit in self._circuits:
self._curvecircuits.append(flsp.PlotCurve(self._graph, type='line', extra={'zorder': 2}))
for _points in self._wpoints:
self._curvewpts.append(flsp.PlotCurve(
self._graph, type='scatter',
color='red', marker='o', markersize=5, extra={'zorder': 20},
))
self._annotationwpts.append(flsp.PlotAnnotation(
self._graph,
halignment='left', xoffset=3, extra={'zorder': 21},
))
for _points in self._spoints:
self._curvespts.append(flsp.PlotCurve(
self._graph, type='scatter',
color='green', marker='o', markersize=5, extra={'zorder': 12},
))
self._annotationspts.append(flsp.PlotAnnotation(
self._graph,
halignment='center', xoffset=10, xtoggle=1, extra={'zorder': 11},
))
self._calcAndUpdate()
self._fig.prepareShow()
self._prepare = False
[docs]
def show(self) -> None:
''' Prepare and display the plot. '''
self.prepareShow()
self._fig.show()
[docs]
def _calcAndUpdate(self) -> None:
''' Recalculate curves and push updated data to all plot objects.
Q is in m3/h and H in m; all values are passed as bare magnitudes.
'''
for i, pump in enumerate(self._pumps):
curve = self._curvepumps[i]
Qpts_p_mag = np.linspace(pump.Qb.magnitude, pump.Qe.magnitude, self._npts)
Hpts_p_mag = pump.calcH(Qpts_p_mag, 1).magnitude
ptrim = np.argmax(Hpts_p_mag<=0)
if ptrim>0:
Qpts_p_mag = Qpts_p_mag[:ptrim]
Hpts_p_mag = Hpts_p_mag[:ptrim]
curve.x = Qpts_p_mag
curve.y = Hpts_p_mag
Qpts_c_mag = np.linspace(0.001, self._Qmax, self._npts)
for i, circuit in enumerate(self._circuits):
curve = self._curvecircuits[i]
Hpts_c_mag = abs(circuit.calcH(Qpts_c_mag, 1).magnitude)
curve.x =Qpts_c_mag
curve.y =Hpts_c_mag
for i, wpoint in enumerate(self._wpoints):
curve = self._curvewpts[i]
annotation = self._annotationwpts[i]
wpoint.update()
curve.x =[wpoint.Qmag]
curve.y =[wpoint.Hmag]
annotation.x =[wpoint.Qmag]
annotation.y =[wpoint.Hmag]
annotation.label = [wpoint.name]
for i, spoint in enumerate(self._spoints):
curve = self._curvespts[i]
annotation = self._annotationspts[i]
spoint.update()
curve.x =[spoint.Qmag]
curve.y =[spoint.Hmag]
annotation.x =[spoint.Qmag]
annotation.y =[spoint.Hmag]
annotation.label = [spoint.name]
[docs]
def _resetControls(self, event: Any) -> Any: # pylint: disable=unused-argument
''' Reset all slider widgets to their initial values. '''
for slider in self._sliders:
slider.widget.reset()