diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index db1b5e3..0b9dd51 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - id: ruff-format - repo: "https://github.com/pre-commit/pre-commit-hooks" - rev: "v4.5.0" + rev: "v4.6.0" hooks: - id: "end-of-file-fixer" - id: "trailing-whitespace" diff --git a/CHANGELOG.md b/CHANGELOG.md index f1b94cf..682e4e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.11.0] - 📦️ Remove scipy dependency - 2024-04-08 + +##### Changes + +- ⚡️ Use simple **1D interpolator** to project lines, instead of `scipy.interpolate.interp1d` +- ✅ Add tests for convex hulls of given points +- ⚡️ Use simple **convex hull algorithm**, to compute zones to group annotated points, instead of using `scipy.spatial.ConvexHull` +- 📦️ Bump version, removing `scipy` dependency + ## [0.10.0] - 📦 Migrate to pydantic v2 - 2024-04-07 Move to recent versions of `pydantic`, and also upgrade internal _linters_, moving to `ruff-format` diff --git a/poetry.lock b/poetry.lock index 54d6ffb..9d8c09a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -928,48 +928,6 @@ files = [ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] -[[package]] -name = "scipy" -version = "1.13.0" -description = "Fundamental algorithms for scientific computing in Python" -optional = false -python-versions = ">=3.9" -files = [ - {file = "scipy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba419578ab343a4e0a77c0ef82f088238a93eef141b2b8017e46149776dfad4d"}, - {file = "scipy-1.13.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:22789b56a999265431c417d462e5b7f2b487e831ca7bef5edeb56efe4c93f86e"}, - {file = "scipy-1.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05f1432ba070e90d42d7fd836462c50bf98bd08bed0aa616c359eed8a04e3922"}, - {file = "scipy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8434f6f3fa49f631fae84afee424e2483289dfc30a47755b4b4e6b07b2633a4"}, - {file = "scipy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:dcbb9ea49b0167de4167c40eeee6e167caeef11effb0670b554d10b1e693a8b9"}, - {file = "scipy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:1d2f7bb14c178f8b13ebae93f67e42b0a6b0fc50eba1cd8021c9b6e08e8fb1cd"}, - {file = "scipy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0fbcf8abaf5aa2dc8d6400566c1a727aed338b5fe880cde64907596a89d576fa"}, - {file = "scipy-1.13.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:5e4a756355522eb60fcd61f8372ac2549073c8788f6114449b37e9e8104f15a5"}, - {file = "scipy-1.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5acd8e1dbd8dbe38d0004b1497019b2dbbc3d70691e65d69615f8a7292865d7"}, - {file = "scipy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ff7dad5d24a8045d836671e082a490848e8639cabb3dbdacb29f943a678683d"}, - {file = "scipy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4dca18c3ffee287ddd3bc8f1dabaf45f5305c5afc9f8ab9cbfab855e70b2df5c"}, - {file = "scipy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:a2f471de4d01200718b2b8927f7d76b5d9bde18047ea0fa8bd15c5ba3f26a1d6"}, - {file = "scipy-1.13.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d0de696f589681c2802f9090fff730c218f7c51ff49bf252b6a97ec4a5d19e8b"}, - {file = "scipy-1.13.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:b2a3ff461ec4756b7e8e42e1c681077349a038f0686132d623fa404c0bee2551"}, - {file = "scipy-1.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf9fe63e7a4bf01d3645b13ff2aa6dea023d38993f42aaac81a18b1bda7a82a"}, - {file = "scipy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e7626dfd91cdea5714f343ce1176b6c4745155d234f1033584154f60ef1ff42"}, - {file = "scipy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:109d391d720fcebf2fbe008621952b08e52907cf4c8c7efc7376822151820820"}, - {file = "scipy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8930ae3ea371d6b91c203b1032b9600d69c568e537b7988a3073dfe4d4774f21"}, - {file = "scipy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5407708195cb38d70fd2d6bb04b1b9dd5c92297d86e9f9daae1576bd9e06f602"}, - {file = "scipy-1.13.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:ac38c4c92951ac0f729c4c48c9e13eb3675d9986cc0c83943784d7390d540c78"}, - {file = "scipy-1.13.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09c74543c4fbeb67af6ce457f6a6a28e5d3739a87f62412e4a16e46f164f0ae5"}, - {file = "scipy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28e286bf9ac422d6beb559bc61312c348ca9b0f0dae0d7c5afde7f722d6ea13d"}, - {file = "scipy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:33fde20efc380bd23a78a4d26d59fc8704e9b5fd9b08841693eb46716ba13d86"}, - {file = "scipy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:45c08bec71d3546d606989ba6e7daa6f0992918171e2a6f7fbedfa7361c2de1e"}, - {file = "scipy-1.13.0.tar.gz", hash = "sha256:58569af537ea29d3f78e5abd18398459f195546bb3be23d16677fb26616cc11e"}, -] - -[package.dependencies] -numpy = ">=1.22.4,<2.3" - -[package.extras] -dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy", "pycodestyle", "pydevtool", "rich-click", "ruff", "types-psutil", "typing_extensions"] -doc = ["jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.12.0)", "jupytext", "matplotlib (>=3.5)", "myst-nb", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0)", "sphinx-design (>=0.4.0)"] -test = ["array-api-strict", "asv", "gmpy2", "hypothesis (>=6.30)", "mpmath", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] - [[package]] name = "setuptools" version = "69.2.0" @@ -1053,4 +1011,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.13" -content-hash = "950789a5aea162b9f3b312c7c75b515f051b574bce7df72e6b7837ba83f3d32b" +content-hash = "0bccb1113d7bfecec1b0374aee94532d3840aa30a5d5e22c3ebf5383e3eef56f" diff --git a/psychrochart/chartdata.py b/psychrochart/chartdata.py index c298472..75c75c6 100644 --- a/psychrochart/chartdata.py +++ b/psychrochart/chartdata.py @@ -18,11 +18,10 @@ GetVapPresFromHumRatio, isIP, ) -from scipy.interpolate import interp1d from psychrochart.models.curves import PsychroCurve, PsychroCurves from psychrochart.models.styles import AnnotationStyle, CurveStyle -from psychrochart.util import solve_curves_with_iteration +from psychrochart.util import Interp1D, solve_curves_with_iteration f_vec_hum_ratio_from_vap_press = np.vectorize(GetHumRatioFromVapPres) f_vec_moist_air_enthalpy = np.vectorize(GetMoistAirEnthalpy) @@ -273,12 +272,7 @@ def make_constant_enthalpy_lines( ) / _factor_out_h() ) - t_sat_interpolator = interp1d( - h_in_sat, - saturation_curve.x_data, - fill_value="extrapolate", - assume_sorted=True, - ) + t_sat_interpolator = Interp1D(h_in_sat, saturation_curve.x_data) h_min = ( GetMoistAirEnthalpy( dbt_min_seen or saturation_curve.x_data[0], @@ -412,12 +406,7 @@ def make_constant_specific_volume_lines( temps_max_constant_v = f_vec_dry_temp_from_spec_vol( np.array(v_objective), w_humidity_ratio_min / _factor_out_w(), pressure ) - t_sat_interpolator = interp1d( - v_in_sat, - saturation_curve.x_data, - fill_value="extrapolate", - assume_sorted=True, - ) + t_sat_interpolator = Interp1D(v_in_sat, saturation_curve.x_data) t_sat_points = solve_curves_with_iteration( "CONSTANT VOLUME", v_objective, diff --git a/psychrochart/plot_logic.py b/psychrochart/plot_logic.py index 26b160e..0247c84 100644 --- a/psychrochart/plot_logic.py +++ b/psychrochart/plot_logic.py @@ -10,7 +10,6 @@ from matplotlib.axes import Axes from matplotlib.path import Path from matplotlib.text import Annotation -from scipy.spatial import ConvexHull, QhullError from psychrochart.chart_entities import ( ChartRegistry, @@ -25,7 +24,7 @@ PsychroCurves, ) from psychrochart.models.styles import CurveStyle, ZoneStyle -from psychrochart.util import mod_color +from psychrochart.util import convex_hull_graham_scan, mod_color def _annotate_label( @@ -427,33 +426,31 @@ def plot_annots_dbt_rh(ax: Axes, annots: ChartAnnots) -> dict[str, Artist]: line_gid = make_item_gid("point", name=point.label or name) reg_artist(line_gid, artist_point, annot_artists) - if ConvexHull is None or not annots.areas: + if not annots.areas: return annot_artists for convex_area in annots.areas: - int_points = np.array( - [annots.get_point_by_name(key) for key in convex_area.point_names] - ) + raw_points = [ + annots.get_point_by_name(key) for key in convex_area.point_names + ] try: - assert len(int_points) >= 3 - hull = ConvexHull(int_points) - except (AssertionError, QhullError): # pragma: no cover - logging.error(f"QhullError with points: {int_points}") - continue + points_hull_x, points_hull_y = convex_hull_graham_scan(raw_points) + except (AssertionError, IndexError): # pragma: no cover + logging.error("Convex hull error with points: %s", raw_points) + return annot_artists area_gid = make_item_gid( "convexhull", name=",".join(convex_area.point_names) ) - for i, simplex in enumerate(hull.simplices): - [artist_contour] = ax.plot( - int_points[simplex, 0], - int_points[simplex, 1], - **convex_area.line_style, - ) - reg_artist(area_gid + f"_s{i+1}", artist_contour, annot_artists) + [artist_contour] = ax.plot( + [*points_hull_x, points_hull_x[0]], + [*points_hull_y, points_hull_y[0]], + **convex_area.line_style, + ) + reg_artist(area_gid + "_edge", artist_contour, annot_artists) [artist_area] = ax.fill( - int_points[hull.vertices, 0], - int_points[hull.vertices, 1], + points_hull_x, + points_hull_y, **convex_area.fill_style, ) reg_artist(area_gid, artist_area, annot_artists) diff --git a/psychrochart/process_logic.py b/psychrochart/process_logic.py index f5bdfbe..6db3d24 100644 --- a/psychrochart/process_logic.py +++ b/psychrochart/process_logic.py @@ -9,7 +9,6 @@ SetUnitSystem, SI, ) -from scipy.interpolate import interp1d from psychrochart.chartdata import ( get_rh_max_min_in_limits, @@ -24,6 +23,7 @@ from psychrochart.chartzones import make_zone_curve from psychrochart.models.config import ChartConfig, ChartLimits, DEFAULT_ZONES from psychrochart.models.curves import PsychroChartModel +from psychrochart.util import Interp1D spec_vol_vec = np.vectorize(psy.GetMoistAirVolume) @@ -53,10 +53,9 @@ def _gen_interior_lines(config: ChartConfig, chart: PsychroChartModel) -> None: # check if sat curve cuts x-axis with T > config.dbt_min dbt_min_seen: float | None = None if chart.saturation.y_data[0] < config.w_min: - temp_sat_interpolator = interp1d( + temp_sat_interpolator = Interp1D( chart.saturation.y_data, chart.saturation.x_data, - assume_sorted=True, ) dbt_min_seen = temp_sat_interpolator(config.w_min) diff --git a/psychrochart/util.py b/psychrochart/util.py index 22d9c0f..e6d7781 100644 --- a/psychrochart/util.py +++ b/psychrochart/util.py @@ -9,6 +9,88 @@ TESTING_MODE = os.getenv("PYTEST_CURRENT_TEST") is not None +class Interp1D: + """Simple 1D interpolation with extrapolation.""" + + def __init__(self, x: Sequence[float], y: Sequence[float]): + self.x = np.array(x) + self.y = np.array(y) + + def __call__(self, x_new: float) -> float: + """Linear interpolation with extrapolation.""" + # Perform linear interpolation + for i in range(len(self.x) - 1): + if self.x[i] <= x_new <= self.x[i + 1]: + slope = (self.y[i + 1] - self.y[i]) / ( + self.x[i + 1] - self.x[i] + ) + return float(self.y[i] + slope * (x_new - self.x[i])) + + # Extrapolation + assert x_new < self.x[0] or x_new > self.x[-1] + i = 1 if x_new < self.x[0] else -1 + slope = (self.y[i] - self.y[i - 1]) / (self.x[i] - self.x[i - 1]) + return float(self.y[i] + slope * (x_new - self.x[i])) + + +def orientation( + p: tuple[float, float], + q: tuple[float, float], + r: tuple[float, float], +) -> int: + """ + Function to find orientation of ordered triplet (p, q, r). + Returns: + 0 : Colinear points + 1 : Clockwise points + 2 : Counterclockwise points + """ + val = (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1]) + if val == 0: # pragma: no cover + return 0 # Colinear + elif val > 0: + return 1 # Clockwise + else: + return 2 # Counterclockwise + + +def convex_hull_graham_scan( + points: list[tuple[float, float]], +) -> tuple[list[float], list[float]]: + """Function to compute the convex hull of a set of 2-D points.""" + # If number of points is less than 3, convex hull is not possible + numpoints = len(points) + assert numpoints >= 3 + + # Find the leftmost point + leftp = min(points) + + # Sort points based on polar angle with respect to the leftmost point + sorted_points = sorted( + [p for p in points if p != leftp], + key=lambda x: ( + np.arctan2(x[1] - leftp[1], x[0] - leftp[0]), + x[0], + x[1], + ), + ) + + # Initialize an empty stack to store points on the convex hull + # Start from the leftmost point and proceed to build the convex hull + hull = [leftp, sorted_points[0]] + for i in range(1, len(sorted_points)): + while ( + len(hull) > 1 + and orientation(hull[-2], hull[-1], sorted_points[i]) != 2 + ): + hull.pop() + hull.append(sorted_points[i]) + + hull_x, hull_y = list(zip(*hull)) + assert len(hull_x) >= 2 + return list(hull_x), list(hull_y) + + def _iter_solver( initial_value: np.ndarray, objective_value: np.ndarray, diff --git a/pyproject.toml b/pyproject.toml index b563dcb..636f621 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ log_date_format = "%Y-%m-%d %H:%M:%S" [tool.poetry] name = "psychrochart" -version = "0.10.0" +version = "0.11.0" description = "A python 3 library to make psychrometric charts and overlay information on them" authors = ["Eugenio Panadero "] packages = [ @@ -92,7 +92,6 @@ include = ["CHANGELOG.md"] [tool.poetry.dependencies] python = ">=3.10,<3.13" matplotlib = ">=3.7" -scipy = ">=1.10" psychrolib = ">=2.5" pydantic = ">=2.3.0" python-slugify = ">=8.0.1" diff --git a/tests/example-charts/test_ha_addon_psychrochart.svg b/tests/example-charts/test_ha_addon_psychrochart.svg new file mode 100644 index 0000000..3ebabd9 --- /dev/null +++ b/tests/example-charts/test_ha_addon_psychrochart.svg @@ -0,0 +1,2606 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.8.4, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_ha_addon_chart.py b/tests/test_ha_addon_chart.py new file mode 100644 index 0000000..2febb3d --- /dev/null +++ b/tests/test_ha_addon_chart.py @@ -0,0 +1,365 @@ +from math import ceil, floor +from typing import Any + +from psychrochart import PsychroChart +from psychrochart.chartdata import ( + gen_points_in_constant_relative_humidity, +) +from psychrochart.models.annots import ChartAnnots, ChartArea +from psychrochart.plot_logic import plot_annots_dbt_rh +from psychrochart.process_logic import set_unit_system +from tests.conftest import store_test_chart + +_MIN_CHART_TEMPERATURE = 20 +_MAX_CHART_TEMPERATURE = 27 +# fmt: off +TEST_EXAMPLE_ZONES = [ + { + "label": "Summer", + "points_x": [23, 28], + "points_y": [40, 60], + "style": { + "edgecolor": [1.0, 0.749, 0.0, 0.8], + "facecolor": [1.0, 0.749, 0.0, 0.2], + "linestyle": "--", + "linewidth": 2, + }, + "zone_type": "dbt-rh", + }, + { + "label": "Winter", + "points_x": [18, 23], + "points_y": [35, 55], + "style": { + "edgecolor": [0.498, 0.624, 0.8], + "facecolor": [0.498, 0.624, 1.0, 0.2], + "linestyle": "--", + "linewidth": 2, + }, + "zone_type": "dbt-rh", + }, +] +TEST_EXAMPLE_FIG_CONFIG = { + "figsize": [16, 9], + "partial_axis": True, + "position": [0, 0, 1, 1], + "title": None, + "x_axis": { + "color": [0.855, 0.145, 0.114], + "linestyle": "-", + "linewidth": 2, + }, + "x_axis_labels": {"color": [0.855, 0.145, 0.114], "fontsize": 10}, + "x_axis_ticks": { + "color": [0.855, 0.145, 0.114], + "direction": "in", + "pad": -20, + }, + "x_label": None, + "y_axis": { + "color": [0.0, 0.125, 0.376], + "linestyle": "-", + "linewidth": 2, + }, + "y_axis_labels": {"color": [0.0, 0.125, 0.376], "fontsize": 10}, + "y_axis_ticks": { + "color": [0.0, 0.125, 0.376], + "direction": "in", + "pad": -20, + }, + "y_label": None, +} +# fmt: on + +TEST_EXAMPLE_CHART_CONFIG = { + "chart_params": { + "constant_h_label": None, + "constant_h_labels": [25, 50, 75], + "constant_h_step": 5, + "constant_humid_label": None, + "constant_humid_label_include_limits": False, + "constant_humid_label_step": 1, + "constant_humid_step": 0.5, + "constant_rh_curves": [20, 40, 50, 60, 80], + "constant_rh_label": None, + "constant_rh_labels": [20, 40, 60], + "constant_rh_labels_loc": 0.8, + "constant_temp_label": None, + "constant_temp_label_include_limits": False, + "constant_temp_label_step": 5, + "constant_temp_step": 5, + "constant_v_label": None, + "constant_v_labels": [0.82, 0.84, 0.86, 0.88], + "constant_v_labels_loc": 0.01, + "constant_v_step": 0.01, + "constant_wet_temp_label": None, + "constant_wet_temp_labels": [10, 15, 20], + "constant_wet_temp_step": 5, + "range_wet_temp": [15, 25], + "range_h": [25, 85], + "range_vol_m3_kg": [0.82, 0.88], + "with_constant_dry_temp": True, + "with_constant_h": True, + "with_constant_humidity": True, + "with_constant_rh": True, + "with_constant_v": True, + "with_constant_wet_temp": True, + "with_zones": True, + }, + "constant_dry_temp": { + "color": [0.855, 0.145, 0.114, 0.7], + "linestyle": ":", + "linewidth": 0.75, + }, + "constant_h": { + "color": [0.251, 0.0, 0.502, 0.7], + "linestyle": "-", + "linewidth": 2, + }, + "constant_humidity": { + "color": [0.0, 0.125, 0.376, 0.7], + "linestyle": ":", + "linewidth": 0.75, + }, + "constant_rh": { + "color": [0.0, 0.498, 1.0, 0.7], + "linestyle": "-.", + "linewidth": 2, + }, + "constant_v": { + "color": [0.0, 0.502, 0.337, 0.7], + "linestyle": "-", + "linewidth": 1, + }, + "constant_wet_temp": { + "color": [0.498, 0.875, 1.0, 0.7], + "linestyle": "-", + "linewidth": 1, + }, + "figure": TEST_EXAMPLE_FIG_CONFIG, + "limits": { + "range_humidity_g_kg": [0, 3], + "range_temp_c": [-30, 10], + "step_temp": 0.5, + "pressure_kpa": 101.42, + }, + "saturation": { + "color": [0.855, 0.145, 0.114], + "linestyle": "-", + "linewidth": 5, + }, + "zones": TEST_EXAMPLE_ZONES, +} + +_EXAMPLE_SENSOR_POINTS = { + "Aseo": { + "xy": (20.63, 55.86), + "label": "Aseo", + "style": {"color": "#007bff", "alpha": 0.9, "markersize": 8}, + }, + "Cocina": { + "xy": (20.15, 55.38), + "label": "Cocina", + "style": {"alpha": 0.9, "color": "#F15346", "markersize": 9}, + }, + "Dormitorio (ESP)": { + "xy": (20.0, 59.1), + "label": "Dormitorio (ESP)", + "style": {"alpha": 0.9, "color": "darkgreen", "markersize": 10}, + }, + "Dormitorio": { + "xy": (19.56, 61.84), + "label": "Dormitorio", + "style": {"alpha": 0.9, "color": "#51E81F", "markersize": 10}, + }, + "Estudio": { + "xy": (20.7, 55.4), + "label": "Estudio", + "style": {"alpha": 0.9, "color": "#FFA067", "markersize": 9}, + }, + "Office": { + "xy": (20.8, 53.0), + "label": "Office", + "style": {"alpha": 0.9, "color": "#bb1247", "markersize": 12}, + }, + "Office-Window": { + "xy": (20.36, 64.33), + "label": "Office-Window", + "style": {"alpha": 0.9, "color": "#bb2b1e", "markersize": 12}, + }, + "Sofa": { + "xy": (22.12, 52.07), + "label": "Sofa", + "style": {"alpha": 0.8, "color": "#E3DB55", "markersize": 10}, + }, + "Galeria (sombra)": { + "xy": (15.51, 85.57), + "label": "Galeria (sombra)", + "style": {"alpha": 0.9, "color": "#fb6150", "markersize": 11}, + }, + "Terraza": { + "xy": (17.1, 99.6), + "label": "Terraza", + "style": {"alpha": 0.7, "color": "#E37207", "markersize": 12}, + }, + "Terraza (sombra)": { + "xy": (15.89, 85.48), + "label": "Terraza (sombra)", + "style": {"alpha": 0.9, "color": "#CC9706", "markersize": 11}, + }, +} +_EXAMPLE_SENSOR_ZONES = [ + ( + [ + "Aseo", + "Cocina", + "Dormitorio (ESP)", + "Dormitorio", + "Estudio", + "Office", + "Office-Window", + "Sofa", + ], + {"color": "darkgreen", "lw": 2, "alpha": 0.5, "ls": ":"}, + {"color": "green", "lw": 0, "alpha": 0.3}, + ), + ( + ["Galeria (sombra)", "Terraza", "Terraza (sombra)"], + {"color": "#E37207", "lw": 1, "alpha": 0.5, "ls": "--"}, + {"color": "#E37207", "lw": 0, "alpha": 0.2}, + ), +] + + +def _get_dynamic_limits(points: dict[str, Any], pressure: float = 101325.0): + pairs_t_rh = [point["xy"] for point in points.values()] + values_t = [p[0] for p in pairs_t_rh] + values_w = gen_points_in_constant_relative_humidity( + values_t, [p[1] for p in pairs_t_rh], pressure + ) + + min_temp = min(floor((min(values_t) - 1) / 3) * 3, _MIN_CHART_TEMPERATURE) + max_temp = max(ceil((max(values_t) + 1) / 3) * 3, _MAX_CHART_TEMPERATURE) + w_min = min(floor((min(values_w) - 1) / 3) * 3, 5.0) + w_max = ceil(max(values_w)) + 2 + return min_temp, max_temp, w_min, w_max + + +def test_ha_addon_psychrochart(): + set_unit_system() + chart_config = TEST_EXAMPLE_CHART_CONFIG.copy() + t_min, t_max, w_min, w_max = _get_dynamic_limits(_EXAMPLE_SENSOR_POINTS) + chart_config["limits"].update( # type: ignore[attr-defined] + { + "range_temp_c": (t_min, t_max), + "range_humidity_g_kg": (w_min, w_max), + } + ) + chart = PsychroChart.create(chart_config) + chart.append_zones() + chart_annots = chart.plot_points_dbt_rh( + _EXAMPLE_SENSOR_POINTS, convex_groups=_EXAMPLE_SENSOR_ZONES + ) + assert isinstance(chart_annots, ChartAnnots) + chart.plot_legend( + frameon=False, fontsize=15, labelspacing=0.8, markerscale=0.8 + ) + store_test_chart( + chart, "test_ha_addon_psychrochart.svg", png=True, svg_rsc=True + ) + + chart_annots.areas.pop(0) + chart_annots.areas.append( + ChartArea( + point_names=[ + "Galeria (sombra)", + "Aseo", + "Cocina", + "Dormitorio (ESP)", + "Dormitorio", + "Estudio", + "Office", + "Terraza (sombra)", + ], + line_style={"color": "#c335e3", "lw": 1, "alpha": 0.5, "ls": "--"}, + fill_style={"color": "#aa89e3", "lw": 0, "alpha": 0.2}, + ) + ) + chart.plot() + plot_annots_dbt_rh(chart.axes, chart_annots) + chart.plot_legend( + frameon=False, fontsize=15, labelspacing=0.8, markerscale=0.8 + ) + store_test_chart(chart, "test_ha_addon_psychrochart-2.svg", png=True) + + chart_annots.areas = [ + ChartArea( + point_names=[ + "Sofa", + "Terraza", + "Cocina", + "Dormitorio (ESP)", + "Office-Window", + "Dormitorio", + ], + line_style={"color": "#50e341", "lw": 1, "alpha": 0.5, "ls": "--"}, + fill_style={"color": "#89e396", "lw": 0, "alpha": 0.2}, + ) + ] + chart.plot() + plot_annots_dbt_rh(chart.axes, chart_annots) + chart.plot_legend( + frameon=False, fontsize=15, labelspacing=0.8, markerscale=0.8 + ) + store_test_chart(chart, "test_ha_addon_psychrochart-3.svg", png=True) + + +def test_bad_convex_hull(): + set_unit_system() + chart_config = TEST_EXAMPLE_CHART_CONFIG.copy() + t_min, t_max, w_min, w_max = _get_dynamic_limits(_EXAMPLE_SENSOR_POINTS) + chart_config["limits"].update( # type: ignore[attr-defined] + { + "range_temp_c": (t_min, t_max), + "range_humidity_g_kg": (w_min, w_max), + } + ) + chart = PsychroChart.create(chart_config) + + points = { + "Aseo": { + "xy": (20.63, 55.86), + "label": "Aseo", + "style": {"color": "#007bff", "alpha": 0.9, "markersize": 8}, + }, + "Cocina": { + "xy": (20.15, 55.38), + "label": "Cocina", + "style": {"alpha": 0.9, "color": "#F15346", "markersize": 9}, + }, + "Dormitorio (ESP)": { + "xy": (20.15, 55.38), + "label": "Dormitorio (ESP)", + "style": {"alpha": 0.9, "color": "darkgreen", "markersize": 10}, + }, + "Dormitorio": { + "xy": (20.63, 55.86), + "label": "Dormitorio", + "style": {"alpha": 0.9, "color": "#51E81F", "markersize": 10}, + }, + "Estudio": { + "xy": (20.15, 55.38), + "label": "Estudio", + "style": {"alpha": 0.9, "color": "#FFA067", "markersize": 9}, + }, + } + zones = [ + ( + ["Aseo", "Cocina", "Dormitorio (ESP)", "Dormitorio"], + {"color": "#E37207", "lw": 1, "alpha": 0.5, "ls": "--"}, + {"color": "#E37207", "lw": 0, "alpha": 0.2}, + ), + ] + + chart.plot_points_dbt_rh(points, convex_groups=zones) + store_test_chart(chart, "test_bad_hull.svg", png=True, svg_rsc=True)