From 00603630ef944334f5c87f157c05afe69556763a Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Mon, 28 Sep 2026 10:15:54 -0600 Subject: [PATCH 1/5] fix(field): link volume field selection to heatmap field --- src/e3sm_siteview/analysis/column_heatmap.py | 1 + src/e3sm_siteview/data_models.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/src/e3sm_siteview/analysis/column_heatmap.py b/src/e3sm_siteview/analysis/column_heatmap.py index 290c8e2..6e1d9ee 100644 --- a/src/e3sm_siteview/analysis/column_heatmap.py +++ b/src/e3sm_siteview/analysis/column_heatmap.py @@ -28,6 +28,7 @@ def __init__(self, server, column_reader): self._build_ui() self.bind_reactivity() + self._compute_heatmap() @property def name(self): diff --git a/src/e3sm_siteview/data_models.py b/src/e3sm_siteview/data_models.py index d5a9c26..9fbe063 100644 --- a/src/e3sm_siteview/data_models.py +++ b/src/e3sm_siteview/data_models.py @@ -169,6 +169,11 @@ def __init__(self, server, **defaults): self.locator.SetDataSet(loop.GetOutput()) self.locator.BuildLocator() + # Bind volume color_by to surface chart + self.volume.watch( + ["color_by"], self.push_volume_color_by_to_surface_chart, sync=True + ) + @property def ctrl(self): return self.server.controller @@ -177,6 +182,9 @@ def ctrl(self): def ctx(self): return self.server.context + def push_volume_color_by_to_surface_chart(self, *_): + self.surface_chart.color_by = self.volume.color_by + @dataclass.watch("time_values", sync=True) def _on_time_values(self, values): self.time_index_max = len(values) - 1 From c4c0797d0f259b8f240574c33aaa99b7683029d3 Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Mon, 28 Sep 2026 10:50:13 -0600 Subject: [PATCH 2/5] fix(lev/z): flip z orientation --- src/e3sm_siteview/io/eam_filters.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/e3sm_siteview/io/eam_filters.py b/src/e3sm_siteview/io/eam_filters.py index b0455a6..3130766 100644 --- a/src/e3sm_siteview/io/eam_filters.py +++ b/src/e3sm_siteview/io/eam_filters.py @@ -100,18 +100,20 @@ def _interface_pressures(cls, table): Prefers the ilev field array; if only lev is present, derives interfaces by averaging adjacent midpoints and extrapolating the top/bottom. + The result is returned in reversed order (bottom first, top last). """ fd = table.GetFieldData() ilev = fd.GetAbstractArray(cls.ILEV) if ilev is not None: - return numpy_support.vtk_to_numpy(ilev).reshape(-1).astype(np.float64) + iface = numpy_support.vtk_to_numpy(ilev).reshape(-1).astype(np.float64) + return iface[::-1].copy() lev = fd.GetAbstractArray(cls.LEV) if lev is not None: levp = numpy_support.vtk_to_numpy(lev).reshape(-1).astype(np.float64) mids = 0.5 * (levp[:-1] + levp[1:]) top = levp[0] - (mids[0] - levp[0]) bot = levp[-1] + (levp[-1] - mids[-1]) - return np.concatenate([[top], mids, [bot]]) + return np.concatenate([[top], mids, [bot]])[::-1].copy() return None # -- execution ------------------------------------------------------------ From d75efe9275a315205af97908f81ed19d761ed1a5 Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Mon, 28 Sep 2026 10:56:25 -0600 Subject: [PATCH 3/5] fix(heatmap): better axis and labels --- src/e3sm_siteview/analysis/column_heatmap.py | 45 +++++++++++++++++--- src/e3sm_siteview/io/eam_reader.py | 8 +++- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/e3sm_siteview/analysis/column_heatmap.py b/src/e3sm_siteview/analysis/column_heatmap.py index 6e1d9ee..b92c179 100644 --- a/src/e3sm_siteview/analysis/column_heatmap.py +++ b/src/e3sm_siteview/analysis/column_heatmap.py @@ -1,5 +1,6 @@ import json +import netCDF4 import numpy as np import plotly.graph_objects as go from trame.app import TrameComponent @@ -53,6 +54,22 @@ def unbind_reactivity(self): def _sync_color_by(self, color_by): self.ctx.setup.surface_chart.color_by = color_by + def _time_labels(self): + tdim = self.single_column_reader.GetDimensions().get("time") + if tdim is None: + return [0] + + if tdim.data is not None and tdim.units: + try: + dates = netCDF4.num2date( + tdim.data, tdim.units, calendar=tdim.calendar or "standard" + ) + return [d.strftime("%Y-%m-%d %H:%M") for d in dates] + except (ValueError, TypeError): + pass + + return list(range(tdim.size)) + def _compute_heatmap(self, *_): field = self.ctx.setup.surface_chart.color_by col_id = self.ctx.setup.surface_chart.column @@ -68,9 +85,10 @@ def _compute_heatmap(self, *_): select_arrays.DisableAllArrays() select_arrays.EnableArray(field) + time_labels = self._time_labels() series = [] levels = None - for t in range(self.ctx.setup.time_index_max): + for t in range(len(time_labels)): col.SetSlicing(json.dumps({"time": t})) col.Update() table = col.GetOutputDataObject(0) @@ -93,17 +111,34 @@ def _compute_heatmap(self, *_): fig = go.Figure( data=go.Heatmap( z=series.T, + x=time_labels, y=levels, colorscale="Viridis", + hovertemplate=( + f"time: %{{x}}
lev: %{{y}}
{field}: %{{z}}" + ), ) ) + # Only label a handful of time steps to keep the axis readable + tick_step = max(1, len(time_labels) // 6) + tick_vals = time_labels[::tick_step] + tick_text = [str(v).replace(" ", "
") for v in tick_vals] + fig.update_layout( - xaxis_title="time", - yaxis={"title": field, "autorange": "reversed"}, + title={"text": field, "x": 0.5, "xanchor": "center"}, + xaxis={ + "title": "time", + "side": "bottom", + "type": "category", + "tickmode": "array", + "tickvals": tick_vals, + "ticktext": tick_text, + "tickangle": 0, + }, + yaxis={"autorange": "reversed"}, showlegend=False, - margin={"b": 0, "l": 0, "r": 0, "t": 0}, + margin={"b": 60, "l": 0, "r": 0, "t": 30}, ) - fig.update_xaxes(side="top") with self.state: self.update_figure(fig) diff --git a/src/e3sm_siteview/io/eam_reader.py b/src/e3sm_siteview/io/eam_reader.py index b3fdf4f..e3d6db0 100644 --- a/src/e3sm_siteview/io/eam_reader.py +++ b/src/e3sm_siteview/io/eam_reader.py @@ -39,6 +39,7 @@ def __init__(self, name, size, data=None): self.size = size self.long_name = None self.units = None + self.calendar = None self.data = data # Store the actual dimension coordinate values def __getitem__(self, key): @@ -50,7 +51,7 @@ def __setitem__(self, key, value): setattr(self, key, value) def update_from_variable(self, var_info): - """Update metadata from netCDF variable info - only long_name and units.""" + """Update metadata from netCDF variable info - long_name, units and calendar.""" try: self.long_name = var_info.getncattr("long_name") except AttributeError: @@ -61,6 +62,11 @@ def update_from_variable(self, var_info): except AttributeError: pass + try: + self.calendar = var_info.getncattr("calendar") + except AttributeError: + pass + def __repr__(self): return f"DimMeta(name='{self.name}', size={self.size}, long_name='{self.long_name}')" From 42dbbbacc99b1e75c9fd3df77077784f3869a415 Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Mon, 28 Sep 2026 14:40:58 -0600 Subject: [PATCH 4/5] fix(data-region): only show regions when needed --- src/e3sm_siteview/components/site_selection.py | 4 ++-- src/e3sm_siteview/viewer.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/e3sm_siteview/components/site_selection.py b/src/e3sm_siteview/components/site_selection.py index 177e4d4..bc564d1 100644 --- a/src/e3sm_siteview/components/site_selection.py +++ b/src/e3sm_siteview/components/site_selection.py @@ -27,8 +27,8 @@ def __init__(self, **_): "`left: ${region[0]}%;top:${region[1]}%;width:${region[2]}%;height:${region[3]}%;`", ), ) - html.Div(classes="coord-equator") - html.Div(classes="coord-meridian") + # html.Div(classes="coord-equator") + # html.Div(classes="coord-meridian") html.Div( classes="coord-radius", style=( diff --git a/src/e3sm_siteview/viewer.py b/src/e3sm_siteview/viewer.py index 868f138..54029fb 100644 --- a/src/e3sm_siteview/viewer.py +++ b/src/e3sm_siteview/viewer.py @@ -44,6 +44,17 @@ def create_viewers(server): w = (lon_max - lon_min) / 360 x_percent.append((0.5 + o, w)) + # make sure we go left to right + # x_percent = [(origin_percent, width_percent), ...] + x_percent.sort() + if len(x_percent) == 2 and x_percent[0][0] + x_percent[0][1] == x_percent[1][0]: + # remove split, just make 1 region + x_percent = [(x_percent[0][0], x_percent[0][1] + x_percent[1][1])] + + if len(x_percent) == 1 and x_percent[0][1] == 1: + # Full region => no highlight + x_percent = [] + server.state.data_regions = [ (lon_o * 100, lat_o, lon_w * 100, lat_h) for lon_o, lon_w in x_percent ] From ff91747a7c8d313bc3f9085e4bd31f0404acc6ac Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Mon, 28 Sep 2026 15:01:46 -0600 Subject: [PATCH 5/5] fix(radius): km to degree conversion --- src/e3sm_siteview/components/site_selection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/e3sm_siteview/components/site_selection.py b/src/e3sm_siteview/components/site_selection.py index bc564d1..636cc28 100644 --- a/src/e3sm_siteview/components/site_selection.py +++ b/src/e3sm_siteview/components/site_selection.py @@ -178,7 +178,7 @@ def _on_radius(self, site_radius, site_radius_unit, **_): site_radius = 1 self.ctx.setup.radius_deg = site_radius * ( - 1.0 if site_radius_unit == "deg" else 111.111 + 1.0 if site_radius_unit == "deg" else 1.0 / 111.111 ) @change("site_lat", "site_lon")