diff --git a/pyproject.toml b/pyproject.toml
index 3d20f99..004ca78 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,10 +6,14 @@ authors = [
{name = "Kitware Inc."},
]
dependencies = [
- "trame>=3.12",
+ "trame>=3.13.2",
"trame-vuetify",
- "trame-rca",
+ "trame-rca[turbo]>=2.6",
"trame-dockview",
+ "trame-dataclass >=2.1.3",
+ "pyproj>=3.6.1",
+ "netCDF4>=1.6.5",
+ "vtk>=9.6",
]
requires-python = ">=3.12"
readme = "README.rst"
@@ -94,7 +98,17 @@ isort.required-imports = []
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["T20", "PLC0415"]
"noxfile.py" = ["T20"]
-"src/**" = ["SIM117"]
+"src/**" = ["SIM117", "T"]
+"src/e3sm_siteview/plugins/**" = [
+ "PLW",
+ "ARG",
+ "RET",
+ "PLC",
+ "SIM",
+ "EM",
+ "PGH",
+ "T",
+]
[tool.semantic_release]
version_toml = [
diff --git a/src/e3sm_siteview/analysis/__init__.py b/src/e3sm_siteview/analysis/__init__.py
new file mode 100644
index 0000000..7a2232b
--- /dev/null
+++ b/src/e3sm_siteview/analysis/__init__.py
@@ -0,0 +1,37 @@
+import importlib.util
+from pathlib import Path
+
+REGISTRY = {}
+
+
+def create_analysis(type, server, reader):
+ klass = REGISTRY.get(type)
+ if klass:
+ return klass(server, reader)
+ return None
+
+
+def register_analysis(type, klass):
+ REGISTRY[type] = klass
+
+
+def create_id_generator():
+ count = 0
+ while True:
+ count += 1
+ yield f"analysis_{count}"
+
+
+def load_all_analysis():
+ for module_path in Path(__file__).parent.glob("*.py"):
+ if module_path.stem[0] == "_":
+ continue
+ name = module_path.stem
+ spec = importlib.util.spec_from_file_location(name, module_path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+
+
+ANALYSIS_ID = create_id_generator()
+
+__all__ = ["ANALYSIS_ID", "create_analysis", "load_all_analysis", "register_analysis"]
diff --git a/src/e3sm_siteview/analysis/viz3d.py b/src/e3sm_siteview/analysis/viz3d.py
new file mode 100644
index 0000000..8de47c6
--- /dev/null
+++ b/src/e3sm_siteview/analysis/viz3d.py
@@ -0,0 +1,67 @@
+import vtkmodules.vtkRenderingOpenGL2 # noqa: F401
+from trame.app import TrameComponent
+from trame.ui.html import DivLayout
+from trame.widgets import html
+from trame.widgets import vtk as vtk_widgets
+
+# VTK factory initialization
+from vtkmodules.vtkInteractionStyle import vtkInteractorStyleSwitch # noqa: F401
+from vtkmodules.vtkRenderingCore import (
+ vtkActor,
+ vtkDataSetMapper,
+ vtkRenderer,
+ vtkRenderWindow,
+ vtkRenderWindowInteractor,
+)
+
+from e3sm_siteview.analysis import ANALYSIS_ID, register_analysis
+
+NAME = "viz"
+
+
+class Viz3D(TrameComponent):
+ def __init__(self, server, reader):
+ super().__init__(server)
+ self._id = next(ANALYSIS_ID)
+ self._reader = reader
+ self._setup_vtk(reader)
+ self._build_ui()
+
+ @property
+ def name(self):
+ return self._id
+
+ def _setup_vtk(self, reader):
+ mapper = vtkDataSetMapper()
+ actor = vtkActor(mapper=mapper)
+ reader >> mapper
+
+ renderer = vtkRenderer()
+ render_window = vtkRenderWindow()
+ render_window.AddRenderer(renderer)
+ render_window.OffScreenRenderingOn()
+
+ renderWindowInteractor = vtkRenderWindowInteractor()
+ renderWindowInteractor.SetRenderWindow(render_window)
+ renderWindowInteractor.GetInteractorStyle().SetCurrentStyleToTrackballCamera()
+
+ renderer.AddActor(actor)
+ renderer.ResetCamera()
+
+ self.actor = actor
+ self.mapper = mapper
+ self.renderer = renderer
+ self.render_window = render_window
+
+ def _build_ui(self):
+ with DivLayout(self.server, self.name, classes="h-100") as self.ui:
+ with html.Div(
+ style="position:absolute;top:0;left:0;width:100%;height:100%;"
+ ):
+ self.view = vtk_widgets.VtkRemoteView(
+ self.render_window,
+ interactive_ratio=1,
+ )
+
+
+register_analysis(NAME, Viz3D)
diff --git a/src/e3sm_siteview/app.py b/src/e3sm_siteview/app.py
index 0ef65a6..459b318 100644
--- a/src/e3sm_siteview/app.py
+++ b/src/e3sm_siteview/app.py
@@ -1,28 +1,44 @@
+from pathlib import Path
+from types import SimpleNamespace
+
from trame.app import TrameApp
-from trame.ui.vuetify3 import VAppLayout
-from trame.widgets import dockview
-from trame.widgets import vuetify3 as v3
-from e3sm_siteview import components as sv_ui
+from e3sm_siteview import pages
+from e3sm_siteview.analysis import load_all_analysis
+from e3sm_siteview.cli import configure_and_parse
+from e3sm_siteview.viewer import E3SMAnalyser
class E3smSiteView(TrameApp):
def __init__(self, server=None):
super().__init__(server, client_type="vue3")
+ # Tab title
+ self.state.trame__title = "E3SM Site View"
+
# --hot-reload arg optional logic
if self.server.hot_reload:
self.server.controller.on_server_reload.add(self._build_ui)
- self._build_ui()
-
- def _build_ui(self, *_args, **_kwargs):
- self.state.trame__title = "E3SM Site View"
- with VAppLayout(self.server, fill_height=True) as self.ui:
- with v3.VLayout():
- sv_ui.View3DTools()
- with v3.VMain():
- dockview.DockView(ctx_name="views_container")
+ args = configure_and_parse(self.server.cli)
+ load_all_analysis()
+ self.ctx.viewers = {}
+
+ # debug
+ connectivity_file = Path(args.cf).resolve()
+ for data_file in args.df:
+ viewer = E3SMAnalyser(
+ self.server,
+ connectivity_file,
+ Path(data_file).resolve(),
+ )
+ self.ctx.viewers[viewer.name] = viewer
+
+ self.ctx.pages = SimpleNamespace(
+ fields=pages.FieldSelectionPage(self.server),
+ viz=pages.VisualizationPage(self.server),
+ site=pages.SiteSelectionPage(self.server),
+ )
def main(server=None, **kwargs):
diff --git a/src/e3sm_siteview/cli.py b/src/e3sm_siteview/cli.py
new file mode 100644
index 0000000..da74a5d
--- /dev/null
+++ b/src/e3sm_siteview/cli.py
@@ -0,0 +1,20 @@
+def configure_and_parse(parser):
+ parser.add_argument(
+ "-cf",
+ help="the nc file with connnectivity information",
+ )
+ parser.add_argument(
+ "-df",
+ nargs="+",
+ help="the nc file with data/variables",
+ )
+ parser.add_argument(
+ "--perf",
+ dest="perf",
+ action="store_true",
+ help="Emit performance timing on stderr ([PERF] lines). Used to "
+ "diagnose where slider-tick cost is going — reader I/O, pipeline, "
+ "rendering, web layer, etc.",
+ )
+
+ return parser.parse_known_args()[0]
diff --git a/src/e3sm_siteview/components/field_selection.py b/src/e3sm_siteview/components/field_selection.py
new file mode 100644
index 0000000..42dd099
--- /dev/null
+++ b/src/e3sm_siteview/components/field_selection.py
@@ -0,0 +1,115 @@
+from trame.widgets import html
+from trame.widgets import vuetify3 as v3
+
+from e3sm_siteview import constants, module
+
+
+class FieldSelection(html.Div):
+ def __init__(self, next_fn, **_):
+ super().__init__(classes="step-pane ")
+ self.server.enable_module(module)
+
+ self.state.field_surface = [
+ {**f, "selected": False} for f in constants.FIELDS_METADATA.values()
+ ]
+ self.state.field_midpoints = [
+ {**f, "selected": False} for f in constants.FIELDS_METADATA.values()
+ ]
+
+ with self:
+ with html.Div(classes="px-6 pt-6 d-flex align-center ga-2"):
+ v3.VTextField(
+ v_model=("fields_filter", ""),
+ placeholder="Search fields (name or description)",
+ prepend_inner_icon="mdi-magnify",
+ variant="outlined",
+ density="comfortable",
+ clearable=True,
+ hide_details=True,
+ )
+ v3.VBtn(
+ "Load ({{ 5 }} + {{ 6 }})",
+ classes="text-none",
+ variant="flat",
+ color="primary",
+ size="large",
+ append_icon="mdi-chevron-right",
+ height=48,
+ click=next_fn,
+ )
+ with html.Div(classes="flex-fill px-6 py-3"):
+ with v3.VExpansionPanels(
+ v_model=("fields_open_panels", [0, 1]),
+ multiple=True,
+ variant="accordion",
+ ):
+ for group in constants.FIELD_GROUPS:
+ with v3.VExpansionPanel():
+ with v3.VExpansionPanelTitle(height="48px", static=True):
+ v3.VIcon(
+ icon=group.icon, classes="mr-3", color=group.color
+ )
+ html.Span(group.label, classes="font-weight-medium")
+ v3.VChip(
+ "1 / 10", # FIXME
+ size="x-small",
+ variant="outlined",
+ color=group.color,
+ classes="ml-4",
+ )
+ v3.VSpacer()
+ v3.VBtn(
+ "Select all",
+ variant="text",
+ v_on_click_stop_prevent=(
+ self.updateSelection,
+ f"['{group.key}', true]",
+ ),
+ classes="mr-2 text-none",
+ density="compact",
+ )
+ v3.VBtn(
+ "Clear",
+ variant="text",
+ v_on_click_stop_prevent=(
+ self.updateSelection,
+ f"['{group.key}', false]",
+ ),
+ classes="mr-2 text-none",
+ density="compact",
+ )
+
+ with v3.VExpansionPanelText(classes="border-t"):
+ with v3.VRow(dense=True):
+ with v3.VCol(
+ v_for=f"field in {group.state_name}",
+ key="field.name",
+ cols=12,
+ sm=6,
+ md=4,
+ xl=3,
+ v_show="utils.e3sm.match(field, fields_filter)",
+ ):
+ with v3.VCheckbox(
+ v_model="field.selected",
+ density="compact",
+ hide_details=True,
+ ):
+ with v3.Template(v_slot_label=True):
+ with html.Div():
+ with html.Div(
+ "{{ field.name }}",
+ classes="font-weight-medium",
+ ):
+ html.Span(
+ "({{ field.units }})",
+ classes="text-caption text-medium-emphasis",
+ )
+ html.Div(
+ "{{ field.description }}",
+ classes="text-caption text-medium-emphasis",
+ style="line-height:1.2;",
+ )
+
+ def updateSelection(self, group_key, select):
+ print("toggle selection for", group_key, select)
diff --git a/src/e3sm_siteview/components/site_selection.py b/src/e3sm_siteview/components/site_selection.py
new file mode 100644
index 0000000..ef7b12c
--- /dev/null
+++ b/src/e3sm_siteview/components/site_selection.py
@@ -0,0 +1,174 @@
+import math
+
+from trame.decorators import change
+from trame.widgets import client, html
+from trame.widgets import vuetify3 as v3
+
+from e3sm_siteview import constants, module
+
+
+class CoordinatePreview(html.Div):
+ def __init__(self, **_):
+ super().__init__(classes="coord-preview mt-4")
+ self.server.enable_module(module)
+
+ self.state.site_marker_x = 0
+ self.state.site_marker_y = 0
+ self.state.site_marker_diameter = 0
+
+ with self:
+ with client.SizeObserver("site_map_size"):
+ html.Div(classes="coord-equator")
+ html.Div(classes="coord-meridian")
+ html.Div(
+ classes="coord-radius",
+ style=(
+ "{ left: site_marker_x + '%', top: site_marker_y + '%', width: site_marker_diameter + 'px', height: site_marker_diameter + 'px' }",
+ ),
+ )
+ html.Div(
+ classes="coord-marker",
+ style=("{ left: site_marker_x + '%', top: site_marker_y + '%' }",),
+ )
+
+ @change("site_lat", "site_lon")
+ def _update_marker(self, site_lat, site_lon, **_):
+ self.state.site_marker_x = ((site_lon + 180) / 360) * 100
+ self.state.site_marker_y = ((90 - site_lat) / 180) * 100
+
+ @change("site_radius", "site_radius_unit", "site_map_size")
+ def _update_radius(self, site_radius, site_radius_unit, site_map_size, **_):
+ if not site_radius:
+ site_radius = 0
+ if site_map_size:
+ size_width = site_map_size["size"]["width"]
+ deg2px = float(size_width / 360)
+ if site_radius_unit == "km":
+ site_radius = float(site_radius) / 111.111 # convert to degree
+
+ base = min(float(site_radius) * deg2px, size_width / 4)
+ self.state.site_marker_diameter = int(0.5 + max(2 * base, 8))
+
+
+class SiteSelection(html.Div):
+ def __init__(self, next_fn, **_):
+ super().__init__(classes="step-pane pa-6")
+ self.server.enable_module(module)
+
+ with self:
+ with v3.VCol():
+ with v3.VSelect(
+ v_model=("site_selected", None),
+ items=("site_all", constants.SITES),
+ item_title="title",
+ item_value="value",
+ label="ARM User Facility",
+ prepend_inner_icon="mdi-domain",
+ variant="outlined",
+ density="comfortable",
+ clearable=True,
+ ):
+ with v3.Template(v_slot_item=" {props, item }"):
+ v3.VListItem(
+ v_bind="props",
+ subtitle=(
+ "utils?.e3sm?.formatCoords(item?.raw?.lat, item?.raw?.lon)",
+ ),
+ )
+ html.Div(
+ "Coordinates",
+ classes="text-overline text-medium-emphasis mb-1",
+ )
+ with v3.VRow(dense=True):
+ with v3.VCol(cols="6"):
+ v3.VTextField(
+ v_model_number=("site_lat", 0),
+ type="number",
+ step="0.001",
+ label="Latitude",
+ suffix=("site_lat < 0 ? '°S':'°N'",),
+ variant="outlined",
+ density="comfortable",
+ rules=(
+ "[(v) => (v >= -90 && v <= 90) || 'Latitude must be between -90 and 90']",
+ ),
+ )
+ with v3.VCol(cols="6"):
+ v3.VTextField(
+ v_model_number=("site_lon", 0),
+ type="number",
+ step="0.001",
+ label="Longitude",
+ suffix=("site_lon < 0 ? '°W':'°E'",),
+ variant="outlined",
+ density="comfortable",
+ rules=(
+ "[(v) => (v >= -180 && v <= 180) || 'Longitude must be between -180 and 180']",
+ ),
+ )
+
+ html.Div(
+ "Region of Interest",
+ classes="text-overline text-medium-emphasis mb-1",
+ )
+ with v3.VRow(dense=True, align="top"):
+ with v3.VCol():
+ v3.VTextField(
+ v_model_number=("site_radius", 5),
+ type="number",
+ step="0.1",
+ min="0",
+ label="Radius",
+ prepend_inner_icon="mdi-radius-outline",
+ variant="outlined",
+ density="comfortable",
+ classes="pr-4",
+ rules=(
+ """[
+ (v) => (v >= 0) || 'Radius must be greater than or equal to 0',
+ (v) => (v / (site_radius_unit === 'km' ? 111.111 : 1) < 90) || 'Radius must be smaller than 90° or 10 000 Km'
+ ]""",
+ ),
+ )
+ with v3.VBtnToggle(
+ v_model=("site_radius_unit", "deg"),
+ color="primary",
+ variant="outlined",
+ divided=True,
+ mandatory=True,
+ classes="mt-1 pr-4",
+ size="small",
+ ):
+ v3.VBtn("km", value="km", classes="text-none")
+ v3.VBtn("deg", value="deg", classes="text-none")
+
+ v3.VBtn(
+ "Validate",
+ classes="text-none mt-1",
+ variant="flat",
+ color="primary",
+ size="large",
+ append_icon="mdi-chevron-right",
+ height=48,
+ click=next_fn,
+ )
+
+ CoordinatePreview()
+
+ @change("site_selected")
+ def _on_site_selected(self, site_selected, **_):
+ lat_lon = constants.SITES_LAT_LON.get(site_selected)
+ if lat_lon:
+ self.state.site_lat = lat_lon[0]
+ self.state.site_lon = lat_lon[1]
+
+ @change("site_lat", "site_lon")
+ def _on_lat_lon(self, site_lat, site_lon, site_selected, **_):
+ if site_selected:
+ lat_lon = constants.SITES_LAT_LON.get(site_selected)
+ if (
+ lat_lon is None
+ or math.fabs(float(site_lat) - lat_lon[0]) > 0.001
+ or math.fabs(float(site_lon) - lat_lon[1]) > 0.001
+ ):
+ self.state.site_selected = None
diff --git a/src/e3sm_siteview/components/tools.py b/src/e3sm_siteview/components/tools.py
index c5bd7d3..64abc05 100644
--- a/src/e3sm_siteview/components/tools.py
+++ b/src/e3sm_siteview/components/tools.py
@@ -52,7 +52,7 @@ def __init__(self, compact="compact_drawer"):
# Clickable tools
# -----------------------------------------------------------------------------
class ActionButton(v3.VTooltip):
- def __init__(self, compact, title, icon, click, keybinding=None):
+ def __init__(self, title, icon, click, compact="compact_drawer", keybinding=None):
super().__init__(text=title, disabled=(f"!{compact}",))
with self:
with v3.Template(v_slot_activator="{ props }"):
diff --git a/src/e3sm_siteview/components/view_drawer.py b/src/e3sm_siteview/components/view_drawer.py
index 287c7c3..1b9c987 100644
--- a/src/e3sm_siteview/components/view_drawer.py
+++ b/src/e3sm_siteview/components/view_drawer.py
@@ -26,20 +26,30 @@ def __init__(self, reset_camera=None):
),
):
tools.AppLogo()
- tools.ResetCamera(click=reset_camera)
+ tools.ActionButton(
+ title="Select site",
+ icon="mdi-target",
+ click=(self.activate_page, "['site']"),
+ )
+ tools.ActionButton(
+ title="Load fields",
+ icon="mdi-list-status",
+ click=(self.activate_page, "['fields']"),
+ )
v3.VDivider(classes="my-1") # ---------------------
+ tools.ResetCamera(click=reset_camera)
- tools.StateImportExport()
- tools.OpenFile()
+ # tools.StateImportExport()
+ # tools.OpenFile()
v3.VDivider(classes="my-1") # ---------------------
- tools.FieldSelection()
- tools.DataSelection()
+ # tools.FieldSelection()
+ # tools.DataSelection()
tools.Animation()
- v3.VDivider(classes="my-1") # ---------------------
+ # v3.VDivider(classes="my-1") # ---------------------
tools.LayoutManagement()
tools.MapProjection()
@@ -63,3 +73,6 @@ def __init__(self, reset_camera=None):
f"{app_version}",
classes="text-center text-caption d-block text-wrap",
)
+
+ def activate_page(self, name):
+ getattr(self.ctx.pages, name).activate()
diff --git a/src/e3sm_siteview/constants.py b/src/e3sm_siteview/constants.py
new file mode 100644
index 0000000..4f5ed56
--- /dev/null
+++ b/src/e3sm_siteview/constants.py
@@ -0,0 +1,205 @@
+from types import SimpleNamespace
+
+FIELD_GROUPS = [
+ SimpleNamespace(
+ key="surface",
+ label="Surface Fields",
+ icon="mdi-layers-outline",
+ color="primary",
+ state_name="field_surface",
+ ),
+ SimpleNamespace(
+ key="volume",
+ label="Atmospheric Fields",
+ icon="mdi-cube-outline",
+ color="secondary",
+ state_name="field_midpoints",
+ ),
+]
+
+FIELDS_METADATA = {
+ "TS": {"dim": 2, "name": "TS", "units": "K", "description": "Surface temperature"},
+ "PS": {"dim": 2, "name": "PS", "units": "Pa", "description": "Surface pressure"},
+ "PSL": {
+ "dim": 2,
+ "name": "PSL",
+ "units": "Pa",
+ "description": "Sea level pressure",
+ },
+ "TREFHT": {
+ "dim": 2,
+ "name": "TREFHT",
+ "units": "K",
+ "description": "Reference height (2m) temperature",
+ },
+ "QREFHT": {
+ "dim": 2,
+ "name": "QREFHT",
+ "units": "kg/kg",
+ "description": "Reference height humidity",
+ },
+ "U10": {"dim": 2, "name": "U10", "units": "m/s", "description": "10m wind speed"},
+ "PRECT": {
+ "dim": 2,
+ "name": "PRECT",
+ "units": "m/s",
+ "description": "Total precipitation rate",
+ },
+ "PRECC": {
+ "dim": 2,
+ "name": "PRECC",
+ "units": "m/s",
+ "description": "Convective precipitation rate",
+ },
+ "TMQ": {
+ "dim": 2,
+ "name": "TMQ",
+ "units": "kg/m²",
+ "description": "Total precipitable water",
+ },
+ "CLDTOT": {
+ "dim": 2,
+ "name": "CLDTOT",
+ "units": "fraction",
+ "description": "Total cloud fraction",
+ },
+ "FSNS": {
+ "dim": 2,
+ "name": "FSNS",
+ "units": "W/m²",
+ "description": "Net solar flux at surface",
+ },
+ "FLNS": {
+ "dim": 2,
+ "name": "FLNS",
+ "units": "W/m²",
+ "description": "Net longwave flux at surface",
+ },
+ "LHFLX": {
+ "dim": 2,
+ "name": "LHFLX",
+ "units": "W/m²",
+ "description": "Surface latent heat flux",
+ },
+ "SHFLX": {
+ "dim": 2,
+ "name": "SHFLX",
+ "units": "W/m²",
+ "description": "Surface sensible heat flux",
+ },
+ "TAUX": {
+ "dim": 2,
+ "name": "TAUX",
+ "units": "N/m²",
+ "description": "Zonal surface wind stress",
+ },
+ "TAUY": {
+ "dim": 2,
+ "name": "TAUY",
+ "units": "N/m²",
+ "description": "Meridional surface wind stress",
+ },
+ "T": {
+ "dim": 3,
+ "name": "T",
+ "units": "K",
+ "description": "Air temperature on model levels",
+ },
+ "U": {
+ "dim": 3,
+ "name": "U",
+ "units": "m/s",
+ "description": "Zonal wind on model levels",
+ },
+ "V": {
+ "dim": 3,
+ "name": "V",
+ "units": "m/s",
+ "description": "Meridional wind on model levels",
+ },
+ "Q": {
+ "dim": 3,
+ "name": "Q",
+ "units": "kg/kg",
+ "description": "Specific humidity on model levels",
+ },
+ "RELHUM": {
+ "dim": 3,
+ "name": "RELHUM",
+ "units": "%",
+ "description": "Relative humidity on model levels",
+ },
+ "CLOUD": {
+ "dim": 3,
+ "name": "CLOUD",
+ "units": "fraction",
+ "description": "Cloud fraction on model levels",
+ },
+ "CLDLIQ": {
+ "dim": 3,
+ "name": "CLDLIQ",
+ "units": "kg/kg",
+ "description": "Cloud liquid water mixing ratio",
+ },
+ "CLDICE": {
+ "dim": 3,
+ "name": "CLDICE",
+ "units": "kg/kg",
+ "description": "Cloud ice mixing ratio",
+ },
+ "OMEGA": {
+ "dim": 3,
+ "name": "OMEGA",
+ "units": "Pa/s",
+ "description": "Vertical pressure velocity",
+ },
+ "Z3": {"dim": 3, "name": "Z3", "units": "m", "description": "Geopotential height"},
+}
+
+SITES = [
+ {
+ "title": "Southern Great Plains (SGP)",
+ "value": "SGP",
+ "lat": 36.607,
+ "lon": 97.488,
+ },
+ {
+ "title": "North Slope of Alaska (NSA)",
+ "value": "NSA",
+ "lat": 71.323,
+ "lon": 156.609,
+ },
+ {
+ "title": "Eastern North Atlantic (ENA)",
+ "value": "ENA",
+ "lat": 39.091,
+ "lon": 28.026,
+ },
+]
+
+SITES_LAT_LON = {site["value"]: (site["lat"], site["lon"]) for site in SITES}
+
+
+VUETIFY_CONFIG = {
+ "theme": {
+ "defaultTheme": "siteviewLight",
+ "themes": {
+ "siteviewLight": {
+ "dark": False,
+ "colors": {
+ "primary": "#1f6f8b",
+ "secondary": "#7d3c98",
+ "background": "#f4f7f9",
+ },
+ },
+ "siteviewDark": {
+ "dark": True,
+ "colors": {
+ "primary": "#6fc3e0",
+ "secondary": "#c996e6",
+ "background": "#0f1a20",
+ },
+ },
+ },
+ },
+}
diff --git a/src/e3sm_siteview/module/__init__.py b/src/e3sm_siteview/module/__init__.py
new file mode 100644
index 0000000..ba47c42
--- /dev/null
+++ b/src/e3sm_siteview/module/__init__.py
@@ -0,0 +1,15 @@
+from pathlib import Path
+
+from e3sm_siteview import __version__
+
+__all__ = [
+ "serve",
+ "styles",
+]
+
+base_url = f"__e3sm_siteview_{__version__}"
+serve_path = str(Path(__file__).with_name("serve").resolve())
+
+serve = {base_url: serve_path}
+styles = [f"{base_url}/style.css"]
+scripts = [f"{base_url}/script.js"]
diff --git a/src/e3sm_siteview/module/serve/Equirectangular-projection.jpg b/src/e3sm_siteview/module/serve/Equirectangular-projection.jpg
new file mode 100644
index 0000000..395656e
Binary files /dev/null and b/src/e3sm_siteview/module/serve/Equirectangular-projection.jpg differ
diff --git a/src/e3sm_siteview/module/serve/script.js b/src/e3sm_siteview/module/serve/script.js
new file mode 100644
index 0000000..cd8eaec
--- /dev/null
+++ b/src/e3sm_siteview/module/serve/script.js
@@ -0,0 +1,13 @@
+window.trame.utils.e3sm = {
+ formatCoords(lat, lon) {
+ if (lat === null || lon === null || lat === undefined || lon === undefined)
+ return "";
+ const ns = lat >= 0 ? "N" : "S";
+ const ew = lon >= 0 ? "E" : "W";
+ return `${Math.abs(lat).toFixed(3)}°${ns}, ${Math.abs(lon).toFixed(3)}°${ew}`;
+ },
+ match(field, query) {
+ // FIXME enable filtering
+ return true;
+ },
+};
diff --git a/src/e3sm_siteview/module/serve/style.css b/src/e3sm_siteview/module/serve/style.css
new file mode 100644
index 0000000..a244e8a
--- /dev/null
+++ b/src/e3sm_siteview/module/serve/style.css
@@ -0,0 +1,126 @@
+.main-shell {
+ height: 100%;
+ overflow: hidden;
+}
+.shell-container {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.stepper-card {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ min-height: 0;
+}
+.stepper-card > .v-stepper-header {
+ flex: 0 0 auto;
+}
+.stepper-card .v-stepper-window {
+ flex: 1 1 auto !important;
+ min-height: 0 !important;
+ height: auto !important;
+ margin: 0 !important;
+ overflow: hidden;
+}
+.stepper-card .v-window__container {
+ height: 100% !important;
+}
+.stepper-card .v-window-item {
+ height: 100%;
+}
+.step-pane {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.step-pane > .flex-fill {
+ max-height: calc(100% - 72px);
+ overflow: auto;
+}
+
+.actions-bar {
+ flex: 0 0 auto;
+}
+
+.coord-preview {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 2 / 1;
+ border-radius: 12px;
+ overflow: hidden;
+ background: url("./Equirectangular-projection.jpg");
+ background-size: 100% 100%;
+ background-position: center;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+}
+
+.coord-equator {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: calc(50% - 2px);
+ border-top: 4px dashed rgba(255, 0, 0, 0.5);
+}
+
+.coord-meridian {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: calc(50% - 1px);
+ border-left: 4px dashed rgba(255, 0, 0, 0.5);
+}
+
+.coord-marker {
+ position: absolute;
+ width: 14px;
+ height: 14px;
+ margin-left: -7px;
+ margin-top: -7px;
+ border-radius: 50%;
+ background: #ff7043;
+ box-shadow:
+ 0 0 0 4px rgba(255, 112, 67, 0.28),
+ 0 2px 6px rgba(0, 0, 0, 0.4);
+ transition:
+ left 0.25s ease,
+ top 0.25s ease;
+}
+
+.coord-radius {
+ position: absolute;
+ border: 2px dashed rgba(255, 213, 79, 0.85);
+ border-radius: 50%;
+ transform: translate(-50%, -50%);
+ transition:
+ left 0.25s ease,
+ top 0.25s ease,
+ width 0.25s ease,
+ height 0.25s ease;
+ pointer-events: none;
+}
+
+.field-chip-2d {
+ background: #26637d22 !important;
+ color: #26637d !important;
+}
+.field-chip-3d {
+ background: #7d3c9822 !important;
+ color: #7d3c98 !important;
+}
+
+.v-theme--siteviewDark .field-chip-2d {
+ color: #6fc3e0 !important;
+ background: #6fc3e022 !important;
+}
+.v-theme--siteviewDark .field-chip-3d {
+ color: #c996e6 !important;
+ background: #c996e622 !important;
+}
+
+.section-card {
+ border-radius: 16px;
+}
diff --git a/src/e3sm_siteview/pages/__init__.py b/src/e3sm_siteview/pages/__init__.py
new file mode 100644
index 0000000..09114bd
--- /dev/null
+++ b/src/e3sm_siteview/pages/__init__.py
@@ -0,0 +1,9 @@
+from .field_selection import FieldSelectionPage
+from .site_selection import SiteSelectionPage
+from .visualization import VisualizationPage
+
+__all__ = [
+ "FieldSelectionPage",
+ "SiteSelectionPage",
+ "VisualizationPage",
+]
diff --git a/src/e3sm_siteview/pages/field_selection.py b/src/e3sm_siteview/pages/field_selection.py
new file mode 100644
index 0000000..89021af
--- /dev/null
+++ b/src/e3sm_siteview/pages/field_selection.py
@@ -0,0 +1,29 @@
+from trame.app import TrameApp
+from trame.ui.vuetify3 import VAppLayout
+
+from e3sm_siteview.components.field_selection import FieldSelection
+
+
+class FieldSelectionPage(TrameApp):
+ def __init__(self, server=None):
+ super().__init__(server)
+ self._build_ui()
+
+ def _build_ui(self):
+ with VAppLayout(self.server) as self.ui:
+ FieldSelection(self.next)
+
+ def next(self):
+ self.ctx.pages.viz.activate()
+
+ def activate(self):
+ self._build_ui()
+
+
+def main():
+ app = FieldSelectionPage()
+ app.server.start()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/e3sm_siteview/pages/site_selection.html b/src/e3sm_siteview/pages/site_selection.html
new file mode 100644
index 0000000..11d27b1
--- /dev/null
+++ b/src/e3sm_siteview/pages/site_selection.html
@@ -0,0 +1,849 @@
+
+
+
+
+
+ E3SM SiteView — Site Selection
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Coordinates
+
+
+
+
+
+
+
+
+
+
+
+ Region of Interest
+
+
+
+
+
+
+
+ km
+ deg
+
+
+
+
+
+
+
+ Schematic preview (equirectangular, not to scale)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ group.label }}
+ {{ group.key === 'surface' ? '2D' : '3D' }}
+
+
+ {{ countSelected(group) }} / {{ group.fields.length
+ }}
+
+
+
+
+
+ Select all
+ Clear
+
+
+
+
+
+
+
+ {{ field.name }}
+ ({{ field.units }})
+
+
+ {{ field.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back
+
+
Theme
+
+
+
+
+
+ {{ formatCoords(latitude, longitude) }} · {{ radiusValue }}
+ {{ radiusUnit === 'km' ? 'km' : 'deg' }} radius
+
+
+
+ {{ selectedFieldCount }} field{{ selectedFieldCount === 1 ?
+ '' : 's' }} selected
+
+
+
+
+ Next: Select Fields
+
+
+
+
+
+
+ View request
+
+
+ Continue
+
+
+
+
+
+
+
+
+
+
+
+
+ Selection payload
+
+
+
+
+
+{{ requestJson }}
+
+
+
+
+
+ Configuration ready — {{ selectedFieldCount }} field(s) over a {{
+ radiusValue }}{{ radiusUnit === 'km' ? 'km' : '°' }} region.
+
+
+
+
+
+
+
+
+
+
diff --git a/src/e3sm_siteview/pages/site_selection.py b/src/e3sm_siteview/pages/site_selection.py
new file mode 100644
index 0000000..a37c239
--- /dev/null
+++ b/src/e3sm_siteview/pages/site_selection.py
@@ -0,0 +1,29 @@
+from trame.app import TrameApp
+from trame.ui.vuetify3 import VAppLayout
+
+from e3sm_siteview.components.site_selection import SiteSelection
+
+
+class SiteSelectionPage(TrameApp):
+ def __init__(self, server=None):
+ super().__init__(server)
+ self._build_ui()
+
+ def _build_ui(self):
+ with VAppLayout(self.server) as self.ui:
+ SiteSelection(self.next)
+
+ def next(self):
+ self.ctx.pages.fields.activate()
+
+ def activate(self):
+ self._build_ui()
+
+
+def main():
+ app = SiteSelectionPage()
+ app.server.start()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/e3sm_siteview/pages/visualization.py b/src/e3sm_siteview/pages/visualization.py
new file mode 100644
index 0000000..5b4a345
--- /dev/null
+++ b/src/e3sm_siteview/pages/visualization.py
@@ -0,0 +1,40 @@
+from trame.app import TrameApp
+from trame.ui.vuetify3 import VAppLayout
+from trame.widgets import client, dockview, vtk
+from trame.widgets import vuetify3 as v3
+
+from e3sm_siteview import components as sv_ui
+
+
+class VisualizationPage(TrameApp):
+ def __init__(self, server=None):
+ super().__init__(server)
+
+ # Deferred UI initialization
+ vtk.initialize(self.server)
+
+ self._build_ui()
+
+ def _build_ui(self):
+ with VAppLayout(self.server, fill_height=True) as self.ui:
+ client.ClientTriggers(mounted=self._load_layout)
+ with v3.VLayout():
+ sv_ui.View3DTools()
+ with v3.VMain():
+ dockview.DockView(ctx_name="views_container")
+
+ def activate(self):
+ self._build_ui()
+
+ def _load_layout(self):
+ for viewer in self.ctx.viewers.values():
+ viewer.add_analysis("viz")
+
+
+def main():
+ app = VisualizationPage()
+ app.server.start()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/e3sm_siteview/plugins/reader.py b/src/e3sm_siteview/plugins/reader.py
new file mode 100644
index 0000000..a8f0025
--- /dev/null
+++ b/src/e3sm_siteview/plugins/reader.py
@@ -0,0 +1,847 @@
+from vtkmodules.numpy_interface import dataset_adapter as dsa
+from vtkmodules.util import numpy_support, vtkConstants
+from vtkmodules.util.vtkAlgorithm import VTKPythonAlgorithmBase
+from vtkmodules.vtkCommonCore import vtkDataArraySelection, vtkPoints
+from vtkmodules.vtkCommonDataModel import vtkCellArray, vtkUnstructuredGrid
+
+try:
+ from paraview import print_error, print_warning
+ from paraview.util.vtkAlgorithm import smproperty, smproxy
+except ImportError:
+
+ def noop(obj):
+ return obj
+
+ class smproxy:
+ @staticmethod
+ def reader(*_, **__):
+ return noop
+
+ @staticmethod
+ def xml(*_):
+ return noop
+
+ class smproperty:
+ @staticmethod
+ def xml(*_):
+ return noop
+
+ @staticmethod
+ def doublevector(*_, **__):
+ return noop
+
+ @staticmethod
+ def dataarrayselection(*_, **__):
+ return noop
+
+ print_error = print
+ print_warning = print
+
+try:
+ import json
+
+ import netCDF4
+ import numpy as np
+
+ _has_deps = True
+except ImportError as ie:
+ print_error(
+ "Missing required Python modules/packages. Algorithms in this module may "
+ f"not work as expected! \n {ie}"
+ )
+ _has_deps = False
+
+try:
+ from e3sm_quickview.utils import perf as _perf
+except ImportError:
+ # Plugin may be loaded by paraview with quickview not on sys.path; fall
+ # back to a no-op shim so the reader still works.
+ from contextlib import contextmanager
+
+ class _perf: # type: ignore[no-redef]
+ @staticmethod
+ def is_enabled():
+ return False
+
+ @staticmethod
+ @contextmanager
+ def timed(label):
+ yield
+
+# Dimensions will be dynamically determined from connectivity and data files
+
+
+class EAMConstants:
+ LEV = "lev"
+ HYAM = "hyam"
+ HYBM = "hybm"
+ ILEV = "ilev"
+ HYAI = "hyai"
+ HYBI = "hybi"
+ P0 = 1e5
+ PS0 = 1e5
+
+
+class DimMeta:
+ """Simple class to store dimension metadata."""
+
+ def __init__(self, name, size, data=None):
+ self.name = name
+ self.size = size
+ self.long_name = None
+ self.units = None
+ self.data = data # Store the actual dimension coordinate values
+
+ def __getitem__(self, key):
+ """Dict-like access to attributes."""
+ return getattr(self, key, None)
+
+ def __setitem__(self, key, value):
+ """Dict-like setting of attributes."""
+ setattr(self, key, value)
+
+ def update_from_variable(self, var_info):
+ """Update metadata from netCDF variable info - only long_name and units."""
+ try:
+ self.long_name = var_info.getncattr("long_name")
+ except AttributeError:
+ pass
+
+ try:
+ self.units = var_info.getncattr("units")
+ except AttributeError:
+ pass
+
+ def __repr__(self):
+ return f"DimMeta(name='{self.name}', size={self.size}, long_name='{self.long_name}')"
+
+
+class VarMeta:
+ """Simple class to store variable metadata."""
+
+ def __init__(self, name, info, horizontal_dim=None):
+ self.name = name
+ self.dimensions = info.dimensions # Store dimensions for slicing
+ self.fillval = np.nan
+ self.long_name = None
+
+ # Extract metadata from info
+ self._extract_metadata(info)
+
+ def _extract_metadata(self, info):
+ """Helper to extract metadata attributes from netCDF variable."""
+ # Try to get fill value from either _FillValue or missing_value
+ for fillattr in ["_FillValue", "missing_value"]:
+ value = self._get_attr(info, fillattr)
+ if value is not None:
+ self.fillval = value
+ break
+
+ # Get long_name if available
+ long_name = self._get_attr(info, "long_name")
+ if long_name is not None:
+ self.long_name = long_name
+
+ def _get_attr(self, info, attr_name):
+ """Safely get an attribute from netCDF variable info."""
+ try:
+ return info.getncattr(attr_name)
+ except (AttributeError, KeyError):
+ return None
+
+ def __getitem__(self, key):
+ """Dict-like access to attributes."""
+ return getattr(self, key, None)
+
+ def __setitem__(self, key, value):
+ """Dict-like setting of attributes."""
+ setattr(self, key, value)
+
+ def __repr__(self):
+ return f"VarMeta(name='{self.name}', dimensions={self.dimensions})"
+
+
+def compare(data, arrays, dim):
+ ref = data[arrays[0]][:].flatten()
+ if len(ref) != dim:
+ raise Exception(
+ "Length of hya_/hyb_ variable does not match the corresponding dimension"
+ )
+ for array in arrays[1:]:
+ comp = data[array][:].flatten()
+ if not np.array_equal(ref, comp):
+ return None
+ return ref
+
+
+def FindSpecialVariable(data, lev, hya, hyb):
+ dim = data.dimensions.get(lev, None)
+ if dim is None:
+ raise Exception(f"{lev} not found in dimensions")
+ dim = dim.size
+ var = np.array(list(data.variables.keys()))
+
+ if lev in var:
+ lev = data[lev][:].flatten()
+ return lev
+
+ _hyai = [v for v in var if hya in v]
+ _hybi = [v for v in var if hyb in v]
+ if len(_hyai) != len(_hybi):
+ raise Exception("Unmatched pair of hya and hyb variables found")
+
+ p0 = data["P0"][:].item() if "P0" in var else EAMConstants.P0
+ ps0 = EAMConstants.PS0
+
+ if len(_hyai) == 1:
+ hyai = data[_hyai[0]][:].flatten()
+ hybi = data[_hyai[1]][:].flatten()
+ if not (len(hyai) == dim and len(hybi) == dim):
+ raise Exception(
+ "Lengths of arrays for hya_ and hyb_ variables do not match"
+ )
+ ldata = ((hyai * p0) + (hybi * ps0)) / 100.0
+ return ldata
+ hyai = compare(data, _hyai, dim)
+ hybi = compare(data, _hybi, dim)
+ if hyai is None or hybi is None:
+ raise Exception("Values within hya_ and hyb_ arrays do not match")
+ ldata = ((hyai * p0) + (hybi * ps0)) / 100.0
+ return ldata
+
+
+# ------------------------------------------------------------------------------
+# A reader example.
+# ------------------------------------------------------------------------------
+def createModifiedCallback(anobject):
+ import weakref
+
+ weakref_obj = weakref.ref(anobject)
+ anobject = None
+
+ def _markmodified(*args, **kwars):
+ o = weakref_obj()
+ if o is not None:
+ o.Modified()
+
+ return _markmodified
+
+
+@smproxy.reader(
+ name="EAMSliceSource",
+ label="EAM Slice Data Reader",
+ extensions="nc",
+ file_description="NETCDF files for EAM",
+)
+@smproperty.xml("""""")
+@smproperty.xml(
+ """
+
+
+ Specify the NetCDF data file name.
+
+ """
+)
+@smproperty.xml(
+ """
+
+
+ Specify the NetCDF connecticity file name.
+
+ """
+)
+@smproperty.xml(
+ """
+
+ JSON representing dimension slices (e.g. {"lev": 0, "ilev": 1})
+
+ """
+)
+@smproperty.xml(
+ """
+
+
+
+ If True, the points of the dataset will be float, otherwise they will be float or double depending
+ on the type of corner_lat and corner_lon variables in the connectivity file.
+
+
+ """
+)
+class EAMSliceSource(VTKPythonAlgorithmBase):
+ def __init__(self):
+ VTKPythonAlgorithmBase.__init__(
+ self, nInputPorts=0, nOutputPorts=1, outputType="vtkUnstructuredGrid"
+ )
+ self._output = vtkUnstructuredGrid()
+
+ self._DataFileName = None
+ self._ConnFileName = None
+ self._ForceFloatPoints = True
+ self._dirty = False
+
+ # Variables for dimension sliders
+ self._time = 0
+ # Dictionaries to store metadata objects
+ self._variables = {} # Will store VarMeta objects by name
+ self._dimensions = {} # Will store DimMeta objects by name
+ self._timeSteps = []
+ # Dictionary to store dimension slices
+ self._slices = {}
+ self._changed_dims = set()
+
+ # vtkDataArraySelection to allow users choice for fields
+ # to fetch from the netCDF data set
+ self._variable_selection = vtkDataArraySelection()
+ # Add observers for the selection arrays
+ self._variable_selection.AddObserver(
+ "ModifiedEvent", createModifiedCallback(self)
+ )
+ # Flag for area var to calculate averages
+ self._areavar = None
+
+ # NetCDF file handle caching
+ self._mesh_dataset = None
+ self._var_dataset = None
+ self._cached_mesh_filename = None
+ self._cached_var_filename = None
+
+ # Geometry caching
+ self._cached_points = None
+ self._cached_cells = None
+ self._cached_cell_types = None
+ self._cached_offsets = None
+ self._cached_ncells2D = None
+
+ # Special variable caching
+ # self._cached_lev = None
+ # self._cached_ilev = None
+ self._cached_area = None
+
+ # Dynamic dimension detection
+ self._horizontal_dim = None
+ self._data_horizontal_dim = None # Matched in data file
+
+ def __del__(self):
+ """Clean up NetCDF file handles on deletion."""
+ self._close_datasets()
+
+ def _close_datasets(self):
+ """Close any open NetCDF datasets."""
+ if self._mesh_dataset is not None:
+ try:
+ self._mesh_dataset.close()
+ except Exception:
+ pass
+ self._mesh_dataset = None
+ if self._var_dataset is not None:
+ try:
+ self._var_dataset.close()
+ except Exception:
+ pass
+ self._var_dataset = None
+
+ def _get_mesh_dataset(self):
+ """Get cached mesh dataset or open a new one."""
+ if (
+ self._ConnFileName != self._cached_mesh_filename
+ or self._mesh_dataset is None
+ ):
+ if self._mesh_dataset is not None:
+ try:
+ self._mesh_dataset.close()
+ except Exception:
+ pass
+ self._mesh_dataset = netCDF4.Dataset(self._ConnFileName, "r")
+ self._cached_mesh_filename = self._ConnFileName
+ return self._mesh_dataset
+
+ def _get_var_dataset(self):
+ """Get cached variable dataset or open a new one."""
+ if self._DataFileName != self._cached_var_filename or self._var_dataset is None:
+ if self._var_dataset is not None:
+ try:
+ self._var_dataset.close()
+ except Exception:
+ pass
+ self._var_dataset = netCDF4.Dataset(self._DataFileName, "r")
+ self._cached_var_filename = self._DataFileName
+ return self._var_dataset
+
+ # Method to clear all the variable names
+ def _clear(self):
+ self._variables.clear()
+
+ # Clear special variable cache when metadata changes
+ self._cached_area = None
+
+ # Clear dimension detection
+ self._data_horizontal_dim = None
+
+ def _identify_horizontal_dimension(self, meshdata, vardata):
+ """Identify horizontal dimension from connectivity and match with data file."""
+ if self._horizontal_dim and self._data_horizontal_dim:
+ return # Already identified
+
+ # Get first dimension from connectivity file
+ conn_dims = list(meshdata.dimensions.keys())
+ if not conn_dims:
+ print_error("No dimensions found in connectivity file")
+ return
+
+ self._horizontal_dim = conn_dims[0]
+ conn_size = meshdata.dimensions[self._horizontal_dim].size
+
+ # Match dimension in data file by size
+ for dim_name, dim_obj in vardata.dimensions.items():
+ if dim_obj.size == conn_size:
+ self._data_horizontal_dim = dim_name
+ return
+
+ print_error(
+ f"Could not match horizontal dimension size {conn_size} in data file"
+ )
+
+ def _clear_geometry_cache(self):
+ """Clear cached geometry data."""
+ self._cached_points = None
+ self._cached_cells = None
+ self._cached_cell_types = None
+ self._cached_offsets = None
+ self._cached_ncells2D = None
+
+ '''
+ Disable the derivation of lev/ilev for the new approach -- the new approach
+ relies on the identified dimensions from the data file and connectivity files.
+ We could reintroduce this later if required.
+
+ def _get_cached_lev(self, vardata):
+ """Get cached lev array or compute and cache it."""
+ if self._cached_lev is None:
+ self._cached_lev = FindSpecialVariable(
+ vardata, EAMConstants.LEV, EAMConstants.HYAM, EAMConstants.HYBM
+ )
+ return self._cached_lev
+
+ def _get_cached_ilev(self, vardata):
+ """Get cached ilev array or compute and cache it."""
+ if self._cached_ilev is None:
+ self._cached_ilev = FindSpecialVariable(
+ vardata, EAMConstants.ILEV, EAMConstants.HYAI, EAMConstants.HYBI
+ )
+ return self._cached_ilev
+ '''
+
+ def _get_cached_area(self, vardata):
+ """Get cached area array or load and cache it."""
+ if self._cached_area is None and self._areavar:
+ data = vardata[self._areavar.name][:].data
+ # Use reshape instead of flatten to avoid copy
+ self._cached_area = data.reshape(-1)
+ # Apply fill value replacement in-place
+ mask = self._cached_area == self._areavar.fillval
+ self._cached_area[mask] = np.nan
+ return self._cached_area
+
+ def _load_variable(self, vardata, varmeta):
+ """Load variable data with dimension-based slicing."""
+ try:
+ # Build slice tuple based on variable's dimensions and user-selected slices
+ slice_tuple = []
+ for dim in varmeta.dimensions:
+ if dim == self._data_horizontal_dim:
+ slice_tuple.append(slice(None))
+ else:
+ # Use all data for unspecified dimensions
+ slice_tuple.append(self._slices.get(dim, 0))
+
+ with _perf.timed(f"reader.netcdf_read.{varmeta.name}"):
+ # Get data with proper slicing
+ data = vardata[varmeta.name][tuple(slice_tuple)].data.reshape(-1)
+ if not np.isnan(varmeta.fillval):
+ with _perf.timed(f"reader.fill_value_scan.{varmeta.name}"):
+ data = data.copy()
+ data[data == varmeta.fillval] = np.nan
+ return data
+ except Exception as e:
+ print_error(f"Error loading variable {varmeta.name}: {e}")
+ # Return empty array on error
+ return np.array([])
+
+ def _get_enabled_arrays(self, var_list, selection_obj):
+ """Get list of enabled variable names from selection object."""
+ enabled = []
+ for varmeta in var_list:
+ if selection_obj.ArrayIsEnabled(varmeta.name):
+ enabled.append(varmeta)
+ return enabled
+
+ def _build_geometry(self, meshdata):
+ """Build and cache geometry data from mesh dataset."""
+ if self._cached_points is not None:
+ # Geometry already cached
+ return
+
+ dims = meshdata.dimensions
+ mvars = np.array(list(meshdata.variables.keys()))
+
+ # Use the identified horizontal dimension
+ if not self._horizontal_dim:
+ print_error("Horizontal dimension not identified in connectivity file")
+ return
+
+ ncells2D = dims[self._horizontal_dim].size
+ self._cached_ncells2D = ncells2D
+
+ # Find lat/lon dimensions
+ latdim = mvars[np.where(np.char.find(mvars, "corner_lat") > -1)][0]
+ londim = mvars[np.where(np.char.find(mvars, "corner_lon") > -1)][0]
+
+ # Build coordinates
+ lat = meshdata[latdim][:].data.reshape(-1)
+ lon = meshdata[londim][:].data.reshape(-1)
+
+ if self._ForceFloatPoints:
+ points_type = np.float32
+ else:
+ points_type = np.float64 if lat.dtype == np.float64 else np.float32
+ coords = np.empty((len(lat), 3), dtype=points_type)
+ coords[:, 0] = lon
+ coords[:, 1] = lat
+ coords[:, 2] = 0.0
+
+ # Create VTK points
+ _coords = dsa.numpyTovtkDataArray(coords)
+ vtk_coords = vtkPoints()
+ vtk_coords.SetData(_coords)
+ self._cached_points = vtk_coords
+
+ # Build cell arrays
+ cellTypes = np.empty(ncells2D, dtype=np.uint8)
+ cellTypes.fill(vtkConstants.VTK_QUAD)
+ self._cached_cell_types = numpy_support.numpy_to_vtk(
+ num_array=cellTypes.ravel(),
+ deep=True,
+ array_type=vtkConstants.VTK_UNSIGNED_CHAR,
+ )
+
+ offsets = np.arange(0, (4 * ncells2D) + 1, 4, dtype=np.int64)
+ self._cached_offsets = numpy_support.numpy_to_vtk(
+ num_array=offsets.ravel(),
+ deep=True,
+ array_type=vtkConstants.VTK_ID_TYPE,
+ )
+
+ cells = np.arange(ncells2D * 4, dtype=np.int64)
+ self._cached_cells = numpy_support.numpy_to_vtk(
+ num_array=cells.ravel(), deep=True, array_type=vtkConstants.VTK_ID_TYPE
+ )
+
+ def _populate_variable_metadata(self):
+ if self._DataFileName is None or self._ConnFileName is None:
+ return
+
+ meshdata = self._get_mesh_dataset()
+ vardata = self._get_var_dataset()
+
+ # Identify horizontal dimensions first
+ self._identify_horizontal_dimension(meshdata, vardata)
+
+ if not self._data_horizontal_dim:
+ print_error("Could not detect horizontal dimension in data file")
+ return
+
+ # Clear existing selection arrays BEFORE adding new ones
+ self._variable_selection.RemoveAllArrays()
+
+ # First pass: collect dimensions used by valid variables
+ all_dimensions = set()
+ for name, info in vardata.variables.items():
+ dims = set(info.dimensions)
+ if self._data_horizontal_dim not in dims:
+ continue
+ varmeta = VarMeta(name, info, self._data_horizontal_dim)
+ if len(dims) == 1 and "area" in name.lower():
+ self._areavar = varmeta
+ if len(dims) > 1:
+ all_dimensions.update(dims)
+ self._variables[name] = varmeta
+ self._variable_selection.AddArray(name)
+
+ # Remove the horizontal dimension from sliceable dimensions
+ all_dimensions.discard(self._data_horizontal_dim)
+
+ # Second pass: only populate _dimensions for dimensions that are:
+ # 1. Used by at least one valid variable
+ # 2. Have arity > 1
+ self._dimensions.clear()
+ for dim_name in all_dimensions:
+ if dim_name in vardata.dimensions:
+ dim_obj = vardata.dimensions[dim_name]
+ if dim_obj.size > 1:
+ dim_meta = DimMeta(dim_name, dim_obj.size)
+ if dim_name in vardata.variables:
+ dim_var = vardata.variables[dim_name]
+ try:
+ dim_meta.data = vardata[dim_name][:].data
+ except Exception:
+ pass
+ dim_meta.update_from_variable(dim_var)
+ self._dimensions[dim_name] = dim_meta
+
+ # Initialize slices for relevant dimensions
+ for dim in self._dimensions:
+ if dim not in self._slices:
+ self._slices[dim] = 0
+
+ self._variable_selection.DisableAllArrays()
+
+ # Clear old timestamps before adding new ones
+ self._timeSteps.clear()
+ if "time" in vardata.variables:
+ timesteps = vardata["time"][:].data.reshape(-1)
+ self._timeSteps.extend(timesteps)
+
+ def SetDataFileName(self, fname):
+ if fname is not None and fname != "None":
+ if fname != self._DataFileName:
+ self._DataFileName = fname
+ self._dirty = True
+ self._clear()
+ # Close old dataset if filename changed
+ if self._cached_var_filename != fname and self._var_dataset is not None:
+ try:
+ self._var_dataset.close()
+ except Exception:
+ pass
+ self._var_dataset = None
+ self._populate_variable_metadata()
+ self.Modified()
+
+ def SetConnFileName(self, fname):
+ if fname != self._ConnFileName:
+ self._ConnFileName = fname
+ self._dirty = True
+ self._clear() # Clear dimension cache
+ # Close old dataset if filename changed
+ if self._cached_mesh_filename != fname and self._mesh_dataset is not None:
+ try:
+ self._mesh_dataset.close()
+ except Exception:
+ pass
+ self._mesh_dataset = None
+ self._clear_geometry_cache()
+ # Re-populate metadata if data file is already set
+ if self._DataFileName:
+ self._populate_variable_metadata()
+ self.Modified()
+
+ def SetForceFloatPoints(self, forceFloatPoints):
+ if self._ForceFloatPoints != forceFloatPoints:
+ self._ForceFloatPoints = forceFloatPoints
+ self.Modified()
+
+ def SetSlicing(self, slice_str):
+ # Parse JSON string containing dimension slices and update self._slices
+
+ if slice_str and slice_str.strip(): # Check for non-empty string
+ try:
+ slice_dict = json.loads(slice_str)
+ # Validate and update slices for provided dimensions
+ invalid_slices = []
+ for dim, slice_val in slice_dict.items():
+ # Check if dimension exists
+ if dim in self._dimensions:
+ dim_meta = self._dimensions[dim]
+ dim_size = dim_meta.size
+ # Validate slice index
+ if isinstance(slice_val, int):
+ if slice_val < 0 or slice_val >= dim_size:
+ # Include dimension long name if available
+ dim_display = f"{dim}"
+ if dim_meta.long_name:
+ dim_display += f" ({dim_meta.long_name})"
+ invalid_slices.append(
+ f"{dim_display}={slice_val} (valid range: 0-{dim_size - 1})"
+ )
+ else:
+ if self._slices.get(dim) != slice_val:
+ self._changed_dims.add(dim)
+ self._slices[dim] = slice_val
+ else:
+ print_error(
+ f"Slice value for '{dim}' must be an integer, got {type(slice_val).__name__}"
+ )
+ else:
+ # Store the slice anyway for dimensions we haven't seen yet
+ # (might be populated later)
+ self._slices[dim] = slice_val
+ if self._dimensions: # Only warn if we have dimension info
+ print_warning(f"Dimension '{dim}' not found in data file")
+
+ if invalid_slices:
+ print_error(f"Invalid slice indices: {', '.join(invalid_slices)}")
+ else:
+ self.Modified()
+
+ except (json.JSONDecodeError, ValueError) as e:
+ print_error(f"Invalid JSON for slicing: {e}")
+ except Exception as e:
+ print_error(f"Error setting slices: {e}")
+
+ def SetCalculateAverages(self, calcavg):
+ if self._avg != calcavg:
+ self._avg = calcavg
+ self.Modified()
+
+ def GetVariables(self):
+ return self._variables
+
+ def GetDimensions(self):
+ return self._dimensions
+
+ @smproperty.doublevector(
+ name="TimestepValues", information_only="1", si_class="vtkSITimeStepsProperty"
+ )
+ def GetTimestepValues(self):
+ return self._timeSteps
+
+ # Array selection API is typical with readers in VTK
+ # This is intended to allow ability for users to choose which arrays to
+ # load. To expose that in ParaView, simply use the
+ # smproperty.dataarrayselection().
+ # This method **must** return a `vtkDataArraySelection` instance.
+ @smproperty.dataarrayselection(name="Variables")
+ def GetSurfaceVariables(self):
+ return self._variable_selection
+
+ def RequestInformation(self, request, inInfo, outInfo):
+ executive = self.GetExecutive()
+ port = outInfo.GetInformationObject(0)
+ port.Remove(executive.TIME_STEPS())
+ port.Remove(executive.TIME_RANGE())
+ if self._timeSteps is not None and len(self._timeSteps) > 0:
+ for t in self._timeSteps:
+ port.Append(executive.TIME_STEPS(), t)
+ port.Append(executive.TIME_RANGE(), self._timeSteps[0])
+ port.Append(executive.TIME_RANGE(), self._timeSteps[-1])
+ return 1
+
+ # TODO : implement request extents
+ def RequestUpdateExtent(self, request, inInfo, outInfo):
+ return super().RequestUpdateExtent(request, inInfo, outInfo)
+
+ def get_time_index(self, outInfo, executive, from_port):
+ timeInfo = outInfo.GetInformationObject(from_port)
+ timeInd = 0
+ if timeInfo.Has(executive.UPDATE_TIME_STEP()) and len(self._timeSteps) > 1:
+ time = timeInfo.Get(executive.UPDATE_TIME_STEP())
+ for t in self._timeSteps:
+ if time <= t:
+ break
+ timeInd = timeInd + 1
+ return timeInd
+ return timeInd
+
+ def RequestData(self, request, inInfo, outInfo):
+ with _perf.timed("reader.RequestData"):
+ return self._RequestDataImpl(request, inInfo, outInfo)
+
+ def _RequestDataImpl(self, request, inInfo, outInfo):
+ if (
+ self._ConnFileName is None
+ or self._ConnFileName == "None"
+ or self._DataFileName is None
+ or self._DataFileName == "None"
+ ):
+ print_error(
+ "Either one or both, the data file or connectivity file, are not provided!"
+ )
+ return 0
+ global _has_deps
+ if not _has_deps:
+ print_error("Required Python module 'netCDF4' or 'numpy' missing!")
+ return 0
+
+ meshdata = self._get_mesh_dataset()
+ vardata = self._get_var_dataset()
+
+ # Ensure dimensions are identified
+ self._identify_horizontal_dimension(meshdata, vardata)
+
+ if not self._horizontal_dim or not self._data_horizontal_dim:
+ print_error("Could not identify required dimensions from files")
+ return 0
+
+ # Build geometry if not cached
+ self._build_geometry(meshdata)
+
+ if self._cached_points is None:
+ print_error("Could not build geometry from connectivity file")
+ return 0
+
+ output_mesh = dsa.WrapDataObject(self._output)
+
+ if self._dirty:
+ self._output = vtkUnstructuredGrid()
+ output_mesh = dsa.WrapDataObject(self._output)
+
+ # Use cached geometry
+ output_mesh.SetPoints(self._cached_points)
+
+ # Create cell array from cached data
+ cellArray = vtkCellArray()
+ cellArray.SetData(self._cached_offsets, self._cached_cells)
+ output_mesh.VTKObject.SetCells(self._cached_cell_types, cellArray)
+
+ self._dirty = False
+
+ # Needed to drop arrays from cached VTK Object
+ to_remove = set()
+ last_num_arrays = output_mesh.CellData.GetNumberOfArrays()
+ for i in range(last_num_arrays):
+ to_remove.add(output_mesh.CellData.GetArrayName(i))
+
+ changed_dims = self._changed_dims
+ for name, varmeta in self._variables.items():
+ if self._variable_selection.ArrayIsEnabled(name):
+ if output_mesh.CellData.HasArray(name):
+ to_remove.remove(name)
+ if changed_dims and not changed_dims.intersection(
+ varmeta.dimensions
+ ):
+ continue
+ data = self._load_variable(vardata, varmeta)
+ output_mesh.CellData.append(data, name)
+
+ self._changed_dims = set()
+
+ area_var_name = "area"
+ if self._areavar and not output_mesh.CellData.HasArray(area_var_name):
+ data = self._get_cached_area(vardata)
+ if data is not None:
+ output_mesh.CellData.append(data, area_var_name)
+ if area_var_name in to_remove:
+ to_remove.remove(area_var_name)
+
+ for var_name in to_remove:
+ output_mesh.CellData.RemoveArray(var_name)
+
+ output = vtkUnstructuredGrid.GetData(outInfo, 0)
+ output.ShallowCopy(self._output)
+
+ return 1
diff --git a/src/e3sm_siteview/viewer.py b/src/e3sm_siteview/viewer.py
new file mode 100644
index 0000000..7a98569
--- /dev/null
+++ b/src/e3sm_siteview/viewer.py
@@ -0,0 +1,33 @@
+from pathlib import Path
+
+from trame.app import TrameComponent, asynchronous
+
+from e3sm_siteview.analysis import create_analysis
+from e3sm_siteview.plugins.reader import EAMSliceSource
+
+
+class E3SMAnalyser(TrameComponent):
+ def __init__(self, server, connectivity_file, data_file):
+ super().__init__(server)
+ self._name = Path(data_file).name
+ self.reader = EAMSliceSource()
+ self.reader.SetDataFileName(str(data_file))
+ self.reader.SetConnFileName(str(connectivity_file))
+ self._analysis = {}
+
+ @property
+ def name(self):
+ return self._name
+
+ def add_analysis(self, type="viz"):
+ analysis = create_analysis(type, self.server, self.reader)
+ if analysis is None:
+ msg = f"Invalid analysis type: {type}"
+ raise ValueError(msg)
+ self._analysis[type] = analysis
+ asynchronous.create_task(
+ self._add_panel(analysis.name, self.name, analysis.name)
+ )
+
+ async def _add_panel(self, panel_id, label, template_name):
+ self.ctx.views_container.add_panel(panel_id, label, template_name)