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 +
+ + + + + + + +
+
+
+
+
+
+ + + + +
+ + + Back + + Theme + + + +
+ + {{ formatCoords(latitude, longitude) }} · {{ radiusValue }} + {{ radiusUnit === 'km' ? 'km' : 'deg' }} radius +
+
+ + {{ selectedFieldCount }} field{{ selectedFieldCount === 1 ? + '' : 's' }} selected +
+ + + +
+
+
+
+ + + + + + 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)