diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e955a9..42fc22e 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.9.1] - ✨ Add zone kind 'dbt-wmax' with vapour content limit - 2023-06-13 + +##### Changes + +- ✨ Add new kind of overlay **zone 'dbt-wmax'**, to define chart areas delimited between db-temps and absolute humidity values, solving #28 +- 🐛 Enable zones defined by 2 points (assume a rectangle defined by left-bottom/right-top coords) +- 🐛 Fix logic for plot regeneration, to plot again if config changes _AFTER_ plotting the chart +- 🐛 Fix ZoneStyle definition when linewidth is 0 and linestyle remains the default (passing inconsistent params to matplotlib) + ## [0.9.0] - ✨ More kinds of chart zones + CSS for SVG styling - 2023-06-12 Define new enclosed areas in chart between constant RH lines and constant volume or enthalpy values, diff --git a/README.md b/README.md index 9c4d9e0..866d44e 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,8 @@ pip install psychrochart ## Features -- **SI** units (with temperatures in celsius for better readability). -- Easy style customization based on [**pydantic**](https://docs.pydantic.dev/latest/) models and config presets for full customization of colors, line styles, line widths, etc.. +- **SI** units (with temperatures in celsius for better readability), with _partial_ compatibility with IP system (imperial units) +- Easy style customization based on [**pydantic**](https://docs.pydantic.dev/latest/) models and config presets for full customization of **chart limits**, included lines and labels, colors, line styles, line widths, etc.. - Psychrometric charts within temperature and humidity ratio ranges, for any pressure\*, with: - **Saturation line** - **Constant RH lines** @@ -41,10 +41,16 @@ pip install psychrochart - **Constant specific volume lines** - **Constant dry-bulb temperature lines** (internal orthogonal grid, vertical) - **Constant humidity ratio lines** (internal orthogonal grid, horizontal) -- Plot legend for each family of lines +- Plot legend for each family of lines, labeled zones and annotations - Specify labels for each family of lines -- **Overlay points, zones, convex hulls, and arrows** -- **Export SVG, PNG files** +- Overlay points, arrows, **data-series** (numpy arrays or pandas series), and convex hulls around points +- Define multiple kinds of **zones limited by psychrometric values**: + - 'dbt-rh' for areas between dry-bulb temperature and relative humidity values, + - 'enthalpy-rh' for areas between constant enthalpy and relative humidity values + - 'volume-rh' for areas between constant volume and relative humidity values + - 'dbt-wmax' for an area between dry-bulb temperature and water vapor content values (:= a rectangle cut by the saturation line), + - 'xy-points' to define arbitrary closed paths in plot coordinates (dbt, abs humidity) +- **Export as SVG, PNG files**, or generate dynamic SVGs with extra CSS and with `chart.make_svg(...)` > NOTE: The ranges of temperature, humidity and pressure where this library should provide good results are within the normal environments for people to live in. > diff --git a/psychrochart/chart.py b/psychrochart/chart.py index 3b0240d..eb80eaf 100644 --- a/psychrochart/chart.py +++ b/psychrochart/chart.py @@ -128,8 +128,7 @@ def rendered(self) -> bool: @property def axes(self) -> Axes: """Return the Axes object plotting the chart if necessary.""" - self.process_chart() - if not self.rendered: + if not self.rendered or self.config.has_changed: self.plot() assert isinstance(self._axes, Axes) return self._axes @@ -422,7 +421,7 @@ def make_svg( svg_definitions: str | None = None, **params, ) -> str: - """Generate chart as SVG and return as text.""" + """Generate chart as SVG, with optional styling, and return as text.""" svg_io = StringIO() self.save(svg_io, canvas_cls=FigureCanvasSVG, **params) svg_io.seek(0) diff --git a/psychrochart/chartzones.py b/psychrochart/chartzones.py index bb6069b..1eccde4 100644 --- a/psychrochart/chartzones.py +++ b/psychrochart/chartzones.py @@ -274,7 +274,7 @@ def _make_zone_delimited_by_vertical_dbt_and_rh( x_data=np.array(temps_zone), y_data=np.array(abs_humid), style=zone.style, - type_curve="dbt-rh", + type_curve=zone.zone_type, label=zone.label, internal_value=random_internal_value() if zone.label is None else None, ) @@ -330,6 +330,119 @@ def _points_to_volume(dbt_values, w_values): ) +def _make_zone_delimited_by_dbt_and_wmax( + zone: ChartZone, + pressure: float, + *, + step_temp: float, + dbt_min: float, + dbt_max: float, + w_min: float, + w_max: float, +) -> PsychroCurve | None: + assert zone.zone_type == "dbt-wmax" + dbt_1, dbt_2 = zone.points_x + w_1, w_2 = zone.points_y + + if dbt_1 > dbt_max or dbt_2 < dbt_min or w_1 > w_max or w_2 < w_min: + # zone outside limits + return None + + w_1 = max(w_1, w_min) + w_2 = min(w_2, w_max) + dbt_1 = max(dbt_1, dbt_min) + dbt_2 = min(dbt_2, dbt_max) + + saturation = make_saturation_line(dbt_1, dbt_2, step_temp, pressure) + if saturation.outside_limits(dbt_min, dbt_max, w_min, w_max): + # just make a rectangle + return PsychroCurve( + x_data=np.array([dbt_1, dbt_2]), + y_data=np.array([w_1, w_2]), + style=zone.style, + type_curve=zone.zone_type, + label=zone.label, + internal_value=w_2, + ) + + # build path clockwise starting in left bottom corner + path_x, path_y = [], [] + if saturation.y_data[0] < w_1: # saturation cuts lower w value + idx_start = (saturation.y_data > w_1).argmax() + t_start, t_end = ( + saturation.x_data[idx_start - 1], + saturation.x_data[idx_start], + ) + w_start, w_end = ( + saturation.y_data[idx_start - 1], + saturation.y_data[idx_start], + ) + t_cut1, _w_cut1 = _crossing_point_between_rect_lines( + segment_1_x=(dbt_1, dbt_2), + segment_1_y=(w_1, w_1), + segment_2_x=(t_start, t_end), + segment_2_y=(w_start, w_end), + ) + path_x.append(t_cut1) + path_y.append(w_1) + else: # saturation cuts left y-axis + idx_start = 0 + t_cut1, w_cut1 = saturation.x_data[0], saturation.y_data[0] + + path_x.append(dbt_1) + path_y.append(w_1) + path_x.append(t_cut1) + path_y.append(w_cut1) + + if saturation.y_data[-1] < w_2: # saturation cuts right dbt_2 + path_x += saturation.x_data[idx_start:].tolist() + path_y += saturation.y_data[idx_start:].tolist() + + t_cut2, w_cut2 = saturation.x_data[-1], saturation.y_data[-1] + path_x.append(t_cut2) + path_y.append(w_cut2) + else: # saturation cuts top w_2 + idx_end = (saturation.y_data < w_2).argmin() + path_x += saturation.x_data[idx_start:idx_end].tolist() + path_y += saturation.y_data[idx_start:idx_end].tolist() + + t_start, t_end = ( + saturation.x_data[idx_end - 1], + saturation.x_data[idx_end], + ) + w_start, w_end = ( + saturation.y_data[idx_end - 1], + saturation.y_data[idx_end], + ) + t_cut2, _w_cut2 = _crossing_point_between_rect_lines( + segment_1_x=(dbt_1, dbt_2), + segment_1_y=(w_2, w_2), + segment_2_x=(t_start, t_end), + segment_2_y=(w_start, w_end), + ) + path_x.append(t_cut2) + path_y.append(w_2) + + path_x.append(dbt_2) + path_y.append(w_2) + + path_x.append(dbt_2) + path_y.append(w_1) + + # repeat 1st point to close path + path_x.append(path_x[0]) + path_y.append(path_y[0]) + + return PsychroCurve( + x_data=np.array(path_x), + y_data=np.array(path_y), + style=zone.style, + type_curve=zone.zone_type, + label=zone.label, + internal_value=w_2, + ) + + def make_zone_curve( zone_conf: ChartZone, *, @@ -370,6 +483,18 @@ def make_zone_curve( w_max=w_max, ) + if zone_conf.zone_type == "dbt-wmax": + # points for zone between abs humid and dbt ranges + return _make_zone_delimited_by_dbt_and_wmax( + zone_conf, + pressure, + step_temp=step_temp, + dbt_min=dbt_min, + dbt_max=dbt_max, + w_min=w_min, + w_max=w_max, + ) + # expect points in plot coordinates! assert zone_conf.zone_type == "xy-points" zone_value = random_internal_value() if zone_conf.label is None else None @@ -377,7 +502,7 @@ def make_zone_curve( x_data=np.array(zone_conf.points_x), y_data=np.array(zone_conf.points_y), style=zone_conf.style, - type_curve="xy-points", + type_curve=zone_conf.zone_type, label=zone_conf.label, internal_value=zone_value, ) diff --git a/psychrochart/models/annots.py b/psychrochart/models/annots.py index 0885bb7..1c17629 100644 --- a/psychrochart/models/annots.py +++ b/psychrochart/models/annots.py @@ -23,7 +23,6 @@ class ChartPoint(BaseModel): class ChartSeries(BaseModel): """Input model for data-series point array annotation.""" - # TODO fusion with PsychroCurve, + pandas ready x_data: np.ndarray y_data: np.ndarray style: dict[str, Any] = Field(default_factory=dict) diff --git a/psychrochart/models/config.py b/psychrochart/models/config.py index 264a7dd..a7866c1 100644 --- a/psychrochart/models/config.py +++ b/psychrochart/models/config.py @@ -39,7 +39,9 @@ color=[0.0, 0.125, 0.376], linewidth=0.75, linestyle=":" ) -ZoneKind = Literal["dbt-rh", "xy-points", "enthalpy-rh", "volume-rh"] +ZoneKind = Literal[ + "dbt-rh", "xy-points", "enthalpy-rh", "volume-rh", "dbt-wmax" +] class ChartFigure(BaseConfig): diff --git a/psychrochart/models/styles.py b/psychrochart/models/styles.py index 6ca76c3..5b32273 100644 --- a/psychrochart/models/styles.py +++ b/psychrochart/models/styles.py @@ -67,5 +67,8 @@ def _color_arr(cls, v, values): return parse_color(v) @root_validator(pre=True) - def _remove_aliases(cls, values): + def _remove_aliases_and_fix_defaults(cls, values): + if values.get("linewidth", 2) == 0: + # avoid matplotlib error with inconsistent line parameters + values["linestyle"] = "-" return reduce_field_abrs(values) diff --git a/psychrochart/plot_logic.py b/psychrochart/plot_logic.py index 3656674..fd30f99 100644 --- a/psychrochart/plot_logic.py +++ b/psychrochart/plot_logic.py @@ -135,15 +135,25 @@ def plot_curve( return {} if isinstance(curve.style, ZoneStyle): - assert len(curve.y_data) > 2 - verts = list(zip(curve.x_data, curve.y_data)) - codes = ( - [Path.MOVETO] - + [Path.LINETO] * (len(curve.y_data) - 2) - + [Path.CLOSEPOLY] - ) - path = Path(verts, codes) - patch = patches.PathPatch(path, **curve.style.dict()) + if len(curve.y_data) == 2: # draw a rectangle! + patch = patches.Rectangle( + (curve.x_data[0], curve.y_data[0]), + width=curve.x_data[1] - curve.x_data[0], + height=curve.y_data[1] - curve.y_data[0], + **curve.style.dict(), + ) + bbox_p = patch.get_extents() + else: + assert len(curve.y_data) > 2 + verts = list(zip(curve.x_data, curve.y_data)) + codes = ( + [Path.MOVETO] + + [Path.LINETO] * (len(curve.y_data) - 2) + + [Path.CLOSEPOLY] + ) + path = Path(verts, codes) + patch = patches.PathPatch(path, **curve.style.dict()) + bbox_p = path.get_extents() ax.add_patch(patch) gid_zone = make_item_gid( "zone", @@ -156,7 +166,6 @@ def plot_curve( artists, ) if curve.label is not None: - bbox_p = path.get_extents() text_x = 0.5 * (bbox_p.x0 + bbox_p.x1) text_y = 0.5 * (bbox_p.y0 + bbox_p.y1) style_params = { diff --git a/pyproject.toml b/pyproject.toml index 8ecf756..fb6e70e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ log_date_format = "%Y-%m-%d %H:%M:%S" [tool.poetry] name = "psychrochart" -version = "0.9.0" +version = "0.9.1" description = "A python 3 library to make psychrometric charts and overlay information on them" authors = ["Eugenio Panadero "] packages = [ diff --git a/tests/test_chart_zones.py b/tests/test_chart_zones.py index e1413c3..636bb00 100644 --- a/tests/test_chart_zones.py +++ b/tests/test_chart_zones.py @@ -154,3 +154,106 @@ def test_invisible_zones(caplog): assert len(caplog.messages) >= 2 assert not chart.artists.zones assert chart.plot_over_saturated_zone() is None + + +def test_max_humid_delimited_zones(): + config = load_config("minimal") + config.limits.range_temp_c = (10, 22) + config.limits.range_humidity_g_kg = (5, 20) + config.limits.step_temp = 1 + + config.chart_params.with_constant_humidity = True + config.chart_params.constant_humid_step = 1.0 + config.chart_params.constant_humid_label_step = 1.0 + config.chart_params.with_constant_dry_temp = True + config.chart_params.constant_temp_step = 1.0 + config.chart_params.constant_temp_label_step = 1.0 + config.chart_params.range_vol_m3_kg = (0.80, 1) + config.chart_params.constant_v_step = 0.01 + config.chart_params.constant_v_labels = [0.88, 0.9, 0.92, 0.94] + + config.constant_rh.linewidth = 1.0 + config.constant_h.linewidth = 0.75 + config.constant_v.linewidth = 0.75 + config.chart_params.with_constant_h = True + config.chart_params.range_h = (10, 100) + config.chart_params.constant_h_step = 5 + config.chart_params.constant_h_labels = [40, 55] + + wmax_z1 = ChartZone( + zone_type="dbt-wmax", + points_x=[12, 40], + points_y=[7, 13], + label="Zone w_max 1", + style=ZoneStyle( + edgecolor=config.constant_h.color, + facecolor="yellow", + linewidth=0.5, + ), + ) + wmax_z2 = ChartZone( + zone_type="dbt-wmax", + points_x=[5, 30], + points_y=[0, 8], + label="Zone w_max 2", + style=ZoneStyle( + edgecolor=config.constant_h.color, + facecolor="#aa000033", + linewidth=1.5, + ), + ) + wmax_z3 = ChartZone( + zone_type="dbt-wmax", + points_x=[36, 45], + points_y=[0, 24], + label="Zone w_max 3", + style=ZoneStyle( + edgecolor=config.constant_h.color, + facecolor="#00aa0033", + linewidth=1.5, + ), + ) + wmax_z4 = ChartZone( + zone_type="dbt-wmax", + points_x=[20, 60], + points_y=[17, 19], + label="Zone w_max 4", + style=ZoneStyle( + edgecolor=config.constant_h.color, + facecolor="#0000aa33", + linewidth=1.5, + ), + ) + config.chart_params.zones = [wmax_z1, wmax_z2, wmax_z3, wmax_z4] + + chart = PsychroChart.create(config) + store_test_chart(chart, "chart-zones-wmax.svg") + assert len(chart.artists.zones) == 6 + + # zoom out to include full zones inside limits + config.limits.range_temp_c = (6, 50) + config.limits.range_humidity_g_kg = (0, 25) + store_test_chart(chart, "chart-zones-wmax-zoom-out.svg") + assert chart.artists.zones + assert len(chart.artists.zones) == 8 + + # remove zones + chart.remove_zones() + svg_no_annot = chart.make_svg() + assert not chart.artists.zones + assert "Zone w_max" not in svg_no_annot + assert "dbt-wmax" not in svg_no_annot + + +def test_sat_no_sat_zones(): + chart = PsychroChart.create("minimal") + chart.config.chart_params.zones.append( + ChartZone( + zone_type="dbt-wmax", + points_x=[chart.config.dbt_min, chart.config.dbt_max], + points_y=[chart.config.w_min, chart.config.w_max], + style=ZoneStyle(edgecolor="k", facecolor="#e4a039", linewidth=0), + ) + ) + chart.plot_over_saturated_zone(color_fill="#5A90E4") + store_test_chart(chart, "chart-saturation-zones.svg")