diff --git a/src/View/Results/PlotTemperature.py b/src/View/Results/PlotTemperature.py new file mode 100644 index 00000000..b9ef76e0 --- /dev/null +++ b/src/View/Results/PlotTemperature.py @@ -0,0 +1,215 @@ +# PlotTemperature.py -- Pamhyr +# Copyright (C) 2026 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# -*- coding: utf-8 -*- + +import numpy as np + +from matplotlib.cm import ScalarMappable +from matplotlib.collections import PolyCollection +from matplotlib.colors import Normalize + +from View.Results.PlotXY import PlotXY + + +class PlotTemperature(PlotXY): + def __init__(self, canvas=None, trad=None, toolbar=None, + results=None, reach_id=0, profile_id=0, + pol_id=1, parent=None): + super(PlotTemperature, self).__init__( + canvas=canvas, + trad=trad, + toolbar=toolbar, + results=results, + reach_id=reach_id, + profile_id=profile_id, + res_id=[0], + parent=parent, + ) + + self._current_pol_id = pol_id + self._global_ranges = {} + self._temperature_zones = None + self._colorbar = None + self._auto_relim_update = False + self._autoscale_update = False + + @property + def results(self): + return self.data + + @results.setter + def results(self, results): + self.data = results + self._timestamps = sorted(results.get("timestamps")) + self._current_timestamp = self._timestamps[-1] + self._global_ranges.clear() + + def draw(self, highlight=None): + if self._colorbar is not None: + self._colorbar.remove() + self._colorbar = None + self.init_axes() + + reach = self.results.river.reach(self._current_reach_id) + if reach.geometry.number_profiles == 0: + self._init = False + return + + temperatures = self._temperatures(reach) + norm = self._temperature_norm() + + self.draw_profiles(reach, self.results.river.reachs) + self.draw_guide_lines(reach) + self._draw_temperature_zones(reach, temperatures, norm) + self.draw_current(reach) + + mappable = self._temperature_zones + if mappable is None: + mappable = ScalarMappable(norm=norm, cmap="coolwarm") + self._colorbar = self.canvas.figure.colorbar( + mappable, ax=self.canvas.axes + ) + self._colorbar.set_label(self._trad["unit_temperature"]) + # self.canvas.axes.set_title( + # f"{self._trad['temperature_map']} — {reach.name}" + # ) + self.canvas.axes.set_aspect("auto") + self._zoom_to_reach_bbox(reach) + self.canvas.draw_idle() + self.toolbar_update() + self._init = True + + def _draw_temperature_zones(self, reach, temperatures, norm): + profiles = reach.profiles + polygons = [] + for index in range(len(profiles) - 1): + current = profiles[index].geometry + following = profiles[index + 1].geometry + if current.number_points == 0 or following.number_points == 0: + continue + polygons.append([ + (current.x()[0], current.y()[0]), + (current.x()[-1], current.y()[-1]), + (following.x()[-1], following.y()[-1]), + (following.x()[0], following.y()[0]), + ]) + + if not polygons: + self._temperature_zones = None + return + + self._temperature_zones = PolyCollection( + polygons, + cmap="coolwarm", + norm=norm, + edgecolors="none", + alpha=0.8, + zorder=1, + ) + self._temperature_zones.set_array( + self._segment_temperatures(temperatures) + ) + self.canvas.axes.add_collection(self._temperature_zones) + + def update(self): + if not self._init or self._temperature_zones is None: + self.draw() + return + + reach = self.results.river.reach(self._current_reach_id) + self._temperature_zones.set_array( + self._segment_temperatures(self._temperatures(reach)) + ) + self.canvas.draw_idle() + + def set_reach(self, reach_id): + self._current_reach_id = reach_id + self._current_profile_id = 0 + self.draw() + + def set_profile(self, profile_id): + self._current_profile_id = profile_id + reach = self.results.river.reach(self._current_reach_id) + profile = reach.profile(profile_id) + self.plot_selected.set_data( + profile.geometry.x(), profile.geometry.y() + ) + self.canvas.draw_idle() + + def set_pollutant(self, pol_id): + self._current_pol_id = pol_id + self.draw() + + def set_timestamp(self, timestamp): + self._current_timestamp = timestamp + self.update() + + def _temperatures(self, reach): + return np.asarray([ + profile.get_ts_key(self._current_timestamp, "pols")[ + self._current_pol_id + ][0] + for profile in reach.profiles + ], dtype=float) + + @staticmethod + def _segment_temperatures(temperatures): + return (temperatures[:-1] + temperatures[1:]) / 2.0 + + def _temperature_norm(self): + minimum, maximum = self._global_temperature_range() + if minimum == maximum: + maximum = minimum + 1.0 + return Normalize(vmin=minimum, vmax=maximum) + + def _zoom_to_reach_bbox(self, reach): + x_values = [ + np.asarray(profile.geometry.x(), dtype=float) + for profile in reach.profiles + if len(profile.geometry.x()) != 0 + ] + y_values = [ + np.asarray(profile.geometry.y(), dtype=float) + for profile in reach.profiles + if len(profile.geometry.y()) != 0 + ] + if not x_values or not y_values: + return + x = np.concatenate(x_values) + y = np.concatenate(y_values) + + x_min, x_max = float(np.min(x)), float(np.max(x)) + y_min, y_max = float(np.min(y)), float(np.max(y)) + x_margin = max((x_max - x_min) * 0.05, 1.0) + y_margin = max((y_max - y_min) * 0.05, 1.0) + self.canvas.axes.set_xlim(x_min - x_margin, x_max + x_margin) + self.canvas.axes.set_ylim(y_min - y_margin, y_max + y_margin) + + def _global_temperature_range(self): + pol_id = self._current_pol_id + if pol_id in self._global_ranges: + return self._global_ranges[pol_id] + + temperatures = [] + for reach in self.results.river.reachs: + for profile in reach.profiles: + for timestamp in self._timestamps: + values = profile.get_ts_key(timestamp, "pols") + if values is not None: + temperatures.append(values[pol_id][0]) + + temperatures = np.asarray(temperatures, dtype=float) + temperatures = temperatures[np.isfinite(temperatures)] + value_range = ( + (float(np.min(temperatures)), float(np.max(temperatures))) + if temperatures.size + else (0.0, 1.0) + ) + self._global_ranges[pol_id] = value_range + return value_range diff --git a/src/View/Results/WindowAdisTT.py b/src/View/Results/WindowAdisTT.py index 7ef9a5c5..cbe7b1d4 100644 --- a/src/View/Results/WindowAdisTT.py +++ b/src/View/Results/WindowAdisTT.py @@ -47,6 +47,7 @@ from View.Tools.Plot.PamhyrCanvas import MplCanvas from View.Tools.Plot.PamhyrToolbar import PamhyrPlotToolbar from View.Results.PlotSedAdis import PlotAdis_dx, PlotAdis_dt +from View.Results.PlotTemperature import PlotTemperature from View.Results.CustomPlot.Plot import CustomPlot from View.Results.CustomExport.CustomExportAdis import ( @@ -225,6 +226,33 @@ class ResultsWindowAdisTT(PamhyrWindow): ) self.plot_cdx.draw() + self.canvas_temperature_map = MplCanvas(width=5, height=4, dpi=100) + self.canvas_temperature_map.setObjectName("canvas_temperature_map") + self.toolbar_temperature_map = PamhyrPlotToolbar( + self.canvas_temperature_map, self, items=[ + "home", "move", "zoom", "save", "iso", "back/forward" + ] + ) + temperature_map_tab = QWidget() + temperature_map_layout = QVBoxLayout(temperature_map_tab) + temperature_map_layout.addWidget(self.toolbar_temperature_map) + temperature_map_layout.addWidget(self.canvas_temperature_map) + self.find(QTabWidget, "tabWidget_c").addTab( + temperature_map_tab, + self._trad["temperature_map"] + ) + self.plot_temperature = PlotTemperature( + canvas=self.canvas_temperature_map, + results=self._results, + reach_id=self._reach_id, + profile_id=self._profile_id, + pol_id=self._current_pol_id[0], + trad=self._trad, + toolbar=self.toolbar_temperature_map, + parent=self, + ) + self.plot_temperature.draw() + # The AdisTT window only displays temperature plots. The code below # belongs to the sediment/pollutant result window and its layouts are # intentionally absent from ResultsAdisTT.ui. @@ -507,6 +535,7 @@ class ResultsWindowAdisTT(PamhyrWindow): self._reach_id = reach_id self.plot_cdt.set_reach(reach_id) self.plot_cdx.set_reach(reach_id) + self.plot_temperature.set_reach(reach_id) self.update_table_selection_reach(reach_id) self.update_table_selection_profile(0) @@ -515,6 +544,7 @@ class ResultsWindowAdisTT(PamhyrWindow): self._profile_id = profile_id self.plot_cdt.set_profile(profile_id) self.plot_cdx.set_profile(profile_id) + self.plot_temperature.set_profile(profile_id) self.update_table_selection_profile(profile_id) @@ -522,10 +552,12 @@ class ResultsWindowAdisTT(PamhyrWindow): self._current_pol_id = [p+1 for p in pol_id] # rm total_sediment self.plot_cdt.set_pollutant(self._current_pol_id) self.plot_cdx.set_pollutant(self._current_pol_id) + self.plot_temperature.set_pollutant(self._current_pol_id[0]) if timestamp is not None: self.plot_cdt.set_timestamp_preserve_view(timestamp) self.plot_cdx.set_timestamp_preserve_view(timestamp) + self.plot_temperature.set_timestamp(timestamp) self._table["raw_data"].set_timestamp(timestamp) @@ -574,6 +606,8 @@ class ResultsWindowAdisTT(PamhyrWindow): self.plot_cdt.draw() self.plot_cdx.draw() + self.plot_temperature.results = self._results + self.plot_temperature.draw() def _reload_slider(self): self._slider_time = self.find(QSlider, f"horizontalSlider_time") diff --git a/src/View/Results/translate.py b/src/View/Results/translate.py index be240b53..d4b103d8 100644 --- a/src/View/Results/translate.py +++ b/src/View/Results/translate.py @@ -46,6 +46,12 @@ class ResultsTranslate(MainTranslate): self._dict['solver'] = _translate("Results", "Solver") self._dict['x'] = _translate("Results", "X (m)") + self._dict["temperature_map"] = _translate( + "Results", "Reach temperature map" + ) + self._dict["temperature"] = _translate( + "Results", "Temperature" + ) self._dict['label_bottom'] = _translate("Results", "Bottom") self._dict['label_water'] = _translate("Results", "Water elevation")