"""Class-based threshold plotting with a contour backend."""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any, cast
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.axes import Axes
from matplotlib.collections import PolyCollection
from matplotlib.colors import ListedColormap, is_color_like
from matplotlib.legend import Legend
from matplotlib.lines import Line2D
from matplotlib.patches import Patch
from pythermalcomfort.plots.matplotlib._base import GridBasePlot
from pythermalcomfort.plots.matplotlib._shared import (
_PYTHERMALCOMFORT_RC,
BasePlotResult,
_configure_regions,
_PlotDefaults,
)
#: Default color for grid cells that fall outside the model's applicability limits.
OUT_OF_MODEL_LIMITS_COLOR: str = _PlotDefaults.color_out_of_model
[docs]
@dataclass
class ThresholdPlotResult(BasePlotResult):
"""Container with handles returned by :meth:`ThresholdPlot.plot`.
Attributes
----------
fig : Figure
Matplotlib figure containing the rendered threshold plot.
ax : Axes
Matplotlib axis containing the rendered threshold plot.
lines : list of Line2D
Contour boundary lines as editable artists.
fills : list of PolyCollection
Filled threshold regions as artists.
legend : Legend or None
Legend artist if ``legend=True``, otherwise ``None``.
"""
lines: list[Line2D]
fills: list[PolyCollection]
legend: Legend | None
def _contour_paths_to_lines(
ax: Axes,
*,
contour_set: Any,
line_opts: Mapping[str, Any],
) -> list[Line2D]:
"""Convert contour paths into editable line artists."""
lines: list[Line2D] = []
for path in contour_set.get_paths():
segments = path.to_polygons(closed_only=False)
x_combined: list[float] = []
y_combined: list[float] = []
for segment in segments:
if len(segment) == 0:
continue
x_combined.extend(segment[:, 0].tolist())
x_combined.append(np.nan)
y_combined.extend(segment[:, 1].tolist())
y_combined.append(np.nan)
if x_combined:
(line,) = ax.plot(x_combined, y_combined, **line_opts)
lines.append(line)
contour_set.remove()
return lines
[docs]
class ThresholdPlot(GridBasePlot):
"""Configure and render threshold regions for a selected model function.
The API is staged and explicit:
1. configure x and y axes,
2. set fixed model parameters,
3. define output thresholds and optional labels/colors,
4. render with :meth:`plot`.
The returned result contains editable Matplotlib artists, so users can apply
additional styling with standard Matplotlib code.
Examples
--------
.. code-block:: python
from pythermalcomfort.models import pmv_ppd_iso
from pythermalcomfort.plots.matplotlib import ThresholdPlot
result = (
ThresholdPlot(pmv_ppd_iso)
.set_x_axis("tdb", 18.0, 34.0, resolution=0.2)
.set_y_axis("rh", 20.0, 100.0, resolution=0.5)
.set_params(vr=0.10, met=1.2, clo=0.5, wme=0.0)
.set_regions(output="pmv", thresholds=[-0.5, 0.5])
.plot(title="PMV Threshold Regions")
)
result.ax.set_xlabel("Air temperature [°C]")
"""
[docs]
def set_regions(
self,
*,
output: str,
thresholds: Sequence[float],
labels: Sequence[str] | None = None,
colors: Sequence[str] | None = None,
) -> ThresholdPlot:
"""Configure output regions.
Parameters
----------
output : str
Output field or column name.
thresholds : sequence of float
Numeric boundary values that divide the output range into regions.
labels : sequence of str, optional
Region labels. Must have length ``len(thresholds) + 1`` when
provided.
colors : sequence of str, optional
Region colors. Must have length ``len(thresholds) + 1`` when
provided.
Returns
-------
ThresholdPlot
Self, to support method chaining.
Raises
------
TypeError
If ``output`` is not a string.
ValueError
If output name is empty, or thresholds/labels/colors are invalid.
"""
self._region_config = _configure_regions(
output=output, thresholds=thresholds, labels=labels, colors=colors
)
return self
def _validate_invalid_color(self, invalid_color: str) -> None:
"""Validate color used to render out-of-model areas."""
if not isinstance(invalid_color, str) or not is_color_like(invalid_color):
raise ValueError("invalid_color must be a valid Matplotlib color string.")
[docs]
def plot(
self,
*,
ax: Axes | None = None,
title: str | None = None,
legend: bool = True,
show_lines: bool = True,
line_kws: Mapping[str, Any] | None = None,
fill_kws: Mapping[str, Any] | None = None,
legend_kws: Mapping[str, Any] | None = None,
invalid_color: str = _PlotDefaults.color_out_of_model,
) -> ThresholdPlotResult:
"""Render threshold regions and contours on a Matplotlib axis.
Parameters
----------
ax : Axes, optional
Existing axis to draw on. If ``None``, a new figure/axis is
created with a default size of ``(7, 4)`` inches.
title : str, optional
Optional axis title.
legend : bool
Whether to draw a legend.
show_lines : bool
Whether to draw threshold contour boundaries.
line_kws : dict, optional
Keyword overrides forwarded to ``ax.plot`` for contour lines.
fill_kws : dict, optional
Keyword overrides forwarded to ``ax.contourf`` for region fills.
Keys ``color`` and ``facecolor`` are reserved and rejected.
legend_kws : dict, optional
Keyword overrides forwarded to ``ax.legend``.
invalid_color : str
Color used for out-of-model/invalid grid areas.
Returns
-------
ThresholdPlotResult
Result with axis and artist handles.
Raises
------
ValueError
If required configuration is missing, plotting inputs are invalid,
or model evaluation/output extraction fails.
"""
with mpl.rc_context(_PYTHERMALCOMFORT_RC):
self._validate_plot_inputs(fill_kws=fill_kws)
self._validate_invalid_color(invalid_color)
rc = self._region_config
line_opts = dict(line_kws or {})
line_opts.setdefault("color", _PlotDefaults.Threshold.line_color)
line_opts.setdefault("linewidth", _PlotDefaults.Threshold.line_linewidth)
fill_opts = dict(fill_kws or {})
fill_opts.setdefault(
"corner_mask", _PlotDefaults.Threshold.fill_corner_mask
)
legend_opts = dict(legend_kws or {})
legend_opts.setdefault("loc", _PlotDefaults.Threshold.legend_loc)
legend_opts.setdefault(
"bbox_to_anchor",
_PlotDefaults.legend_bbox_to_anchor_with_title
if title is not None
else _PlotDefaults.Threshold.legend_bbox_to_anchor,
)
if ax is None:
fig, ax = plt.subplots(figsize=_PlotDefaults.figsize)
else:
fig = ax.figure
x_min, x_max, y_min, y_max, x, y = self._build_grid()
z = self._evaluate_grid_output(x=x, y=y, output_name=rc.output_name)
finite = np.isfinite(z)
invalid = ~finite
z_masked = np.ma.masked_invalid(z)
extended_levels = [-np.inf, *rc.thresholds, np.inf]
fills: list[PolyCollection] = []
if finite.any():
filled_contours = ax.contourf(
x,
y,
z_masked,
levels=extended_levels,
colors=rc.colors,
extend="neither",
**fill_opts,
)
if hasattr(filled_contours, "collections"):
fills.extend(
cast(list[PolyCollection], list(filled_contours.collections))
)
else:
fills.append(cast(PolyCollection, filled_contours))
if invalid.any():
invalid_cells = (
invalid[:-1, :-1]
| invalid[1:, :-1]
| invalid[:-1, 1:]
| invalid[1:, 1:]
)
invalid_mask = np.ma.masked_where(
~invalid_cells,
np.ones_like(invalid_cells, dtype=float),
)
ax.pcolormesh(
x,
y,
invalid_mask,
cmap=ListedColormap([invalid_color]),
shading="flat",
zorder=_PlotDefaults.Threshold.zorder_invalid,
)
lines: list[Line2D] = []
if show_lines and finite.any():
contour_lines = ax.contour(
x, y, z_masked, levels=rc.thresholds, antialiased=True
)
lines = _contour_paths_to_lines(
ax,
contour_set=contour_lines,
line_opts=line_opts,
)
legend_artist: Legend | None = None
if legend:
handles = [
Patch(
facecolor=color,
alpha=fill_opts.get("alpha", _PlotDefaults.fill_alpha),
label=label,
)
for label, color in zip(rc.labels, rc.colors, strict=False)
]
if invalid.any():
handles.append(
Patch(
facecolor=invalid_color,
alpha=1.0,
label="Out of model limits",
)
)
legend_opts.setdefault(
"ncol", min(len(handles), _PlotDefaults.Threshold.legend_ncol_max)
)
legend_artist = ax.legend(
handles=handles,
**legend_opts,
)
ax.set_xlim(x_min, x_max)
ax.set_ylim(y_min, y_max)
ax.set_xlabel(self._x_axis.name)
ax.set_ylabel(self._y_axis.name)
if title is not None:
ax.set_title(
title, y=_PlotDefaults.title_y_with_legend if legend else None
)
return ThresholdPlotResult(
fig=fig,
ax=ax,
lines=lines,
fills=fills,
legend=legend_artist,
)