diff --git a/pyproject.toml b/pyproject.toml index ca96ca9..4ef1472 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,12 +6,13 @@ authors = [ {name = "Kitware Inc."}, ] dependencies = [ - "trame>=3.12", + "trame >=3.13.2", "trame-vuetify", - "trame-rca", + "trame-rca[turbo] >=2.6", + "trame-vtk", "trame-dockview", - "vtk>=9.3", - "numpy", + "trame-dataclass >=2.1.3", + "pyproj>=3.6.1", "netCDF4>=1.6.5", ] requires-python = ">=3.12" @@ -100,8 +101,18 @@ isort.required-imports = [] [tool.ruff.lint.per-file-ignores] "tests/**" = ["T20", "PLC0415"] "noxfile.py" = ["T20"] -"src/**" = ["SIM117"] "examples/**" = ["T20", "INP001"] +"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..88f32a8 --- /dev/null +++ b/src/e3sm_siteview/analysis/viz3d.py @@ -0,0 +1,242 @@ +import vtkmodules.vtkRenderingOpenGL2 # noqa: F401 +from paraview import simple +from trame.app import TrameComponent +from trame.ui.html import DivLayout +from trame.widgets import html +from trame.widgets import paraview as pvw +from trame.widgets import vuetify3 as v3 + +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._subscriptions = [] + self._setup_pv(reader) + self._build_ui() + + @property + def name(self): + return self._id + + def _setup_pv(self, reader): + self.view = simple.CreateRenderView() + self.view.GetRenderWindow().OffScreenRenderingOn() + self.representation_surface = simple.Show( + simple.OutputPort(reader, 0), + self.view, + ) + self.representation_volume = simple.Show( + simple.OutputPort(reader, 1), + self.view, + Scale=[1.0, 1.0, 0.1], + Visibility=0, + ) + + # Attach listeners + self._subscriptions.append( + self.ctx.setup.watch(["active_viz"], self._on_volume_change) + ) + self._subscriptions.append( + self.ctx.setup.volume.watch(["color_by"], self._on_volume_change) + ) + self._subscriptions.append( + self.ctx.setup.watch(["active_viz"], self._on_surface_change) + ) + self._subscriptions.append( + self.ctx.setup.surface.watch(["color_by"], self._on_surface_change) + ) + + def _on_surface_change(self, *_): + self.representation_surface.Visibility = "surface" in self.ctx.setup.active_viz + self.representation_surface.ColorBy(("CELLS", self.ctx.setup.surface.color_by)) + self.representation_surface.RescaleTransferFunctionToDataRange(False, True) + self.html_view.update() + + def _on_volume_change(self, *_): + self.representation_volume.Visibility = "volume" in self.ctx.setup.active_viz + self.representation_volume.ColorBy(("CELLS", self.ctx.setup.volume.color_by)) + self.representation_volume.RescaleTransferFunctionToDataRange(False, True) + self.html_view.update() + + 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.html_view = pvw.VtkRemoteView( + self.view, + interactive_ratio=1, + ) + self.ctrl.render.add(self.html_view.update) + self.ctrl.reset_camera.add(self.html_view.reset_camera) + with v3.VCard( + style="position:absolute;right:1rem;top:1rem;z-index:100;" + ): + v3.VBtn( + icon="mdi-crop-free", + classes="rounded", + click=self.html_view.reset_camera, + ) + with self.ctx.setup.provide_as("controls"): + with html.Div( + style="position:absolute;left:1rem;top:1rem;z-index:100;", + classes="d-flex flex-column ga-2 align-start", + ): + with v3.VCard( + classes="d-flex align-center", + v_if="controls.active_viz.includes('cloud')", + ): + v3.VBtn( + icon="mdi-weather-cloudy", + density="comfortable", + variant="plain", + classes="rounded ma-1", + click="controls.cloud.show = !controls.cloud.show", + ) + v3.VSlider( + v_show="controls.cloud.show", + v_model="controls.cloud.opacity", + min=0, + max=1, + step=0.01, + style="width: 300px", + density="compact", + hide_details=True, + ) + with v3.VCard( + classes="d-flex align-center", + v_if="controls.active_viz.includes('surface')", + ): + v3.VBtn( + icon="mdi-layers-outline", + density="comfortable", + variant="plain", + classes="rounded ma-1", + click="controls.surface.show = !controls.surface.show", + ) + v3.VSelect( + v_show="controls.surface.show", + v_model=("controls.surface.color_by", None), + items=( + "controls.variables_2d.filter(v => v.selected).map(v => v.name)", + ), + density="compact", + hide_details=True, + variant="flat", + style="width: 300px", + ) + + with v3.VCard( + classes="d-flex align-center", + v_if="controls.active_viz.includes('volume') || controls.active_viz.includes('slice')", + ): + v3.VBtn( + icon="mdi-cube-outline", + density="comfortable", + variant="plain", + classes="rounded ma-1", + click="controls.volume.show = !controls.volume.show", + ) + v3.VSelect( + v_show="controls.volume.show", + v_model=("controls.volume.color_by", None), + items=( + "controls.variables_3d.filter(v => v.selected).map(v => v.name)", + ), + density="compact", + hide_details=True, + variant="flat", + style="width: 300px", + ) + + with v3.VCard( + classes="d-flex align-center", + v_if="controls.active_viz.includes('slice')", + ): + v3.VBtn( + icon="mdi-flip-vertical", + density="comfortable", + variant="plain", + classes="rounded ma-1", + click="controls.slice.show = !controls.slice.show", + ) + v3.VNumberInput( + v_show="controls.slice.show", + v_model="controls.slice.orientation", + step=[1], + min=[-90], + max=[90], + hide_details=True, + density="compact", + prepend_inner_icon="mdi-compass-outline", + style="width: 115px", + control_variant="stacked", + variant="flat", + classes="border-e-thin border-s-thin", + ) + v3.VSlider( + v_show="controls.slice.show", + v_model="controls.slice.altitude", + step="0.1", + min=("controls.slice.altitude_min",), + max=("controls.slice.altitude_max",), + hide_details=True, + density="comfortable", + prepend_icon="mdi-altimeter", + style="width: 400px", + classes="ml-2", + ) + + with v3.VCard( + classes="d-flex align-center", + v_if="controls.active_viz.includes('find-data')", + ): + v3.VBtn( + icon="mdi-magnify-scan", + density="comfortable", + variant="plain", + classes="rounded ma-1", + click="controls.find_data.show = !controls.find_data.show", + ) + v3.VTextField( + v_show="controls.find_data.show", + v_model="controls.find_data.formula", + hide_details=True, + density="compact", + prepend_inner_icon="mdi-sigma", + style="width: 300px", + variant="flat", + classes="border-e-thin border-s-thin", + ) + + with v3.VCard( + classes="d-flex align-center", + v_if="controls.active_viz.includes('crop-column')", + ): + v3.VBtn( + icon="mdi-sort", + density="comfortable", + variant="plain", + classes="rounded ma-1", + click="controls.column.show = !controls.column.show", + ) + v3.VRangeSlider( + v_show="controls.column.show", + v_model="controls.column.altitude_range", + min=("controls.slice.altitude_min",), + max=("controls.slice.altitude_max",), + step="0.1", + hide_details=True, + density="compact", + style="width: 300px", + variant="flat", + ) + + +register_analysis(NAME, Viz3D) diff --git a/src/e3sm_siteview/app.py b/src/e3sm_siteview/app.py index 0ef65a6..4fe1689 100644 --- a/src/e3sm_siteview/app.py +++ b/src/e3sm_siteview/app.py @@ -1,28 +1,29 @@ +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.viewer import create_viewers 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() + create_viewers(self.server) - 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") + 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..2915505 --- /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..e738b04 --- /dev/null +++ b/src/e3sm_siteview/components/field_selection.py @@ -0,0 +1,120 @@ +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) + + 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 self.ctx.setup.provide_as("fields"): + 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( + f"{{{{fields.{group.state_name}.filter(v=>v.selected).length}}}} / {{{{ fields.{group.state_name}.length }}}}", # 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.state_name}', true, fields_filter]", + ), + classes="mr-2 text-none", + density="compact", + ) + v3.VBtn( + "Clear", + variant="text", + v_on_click_stop_prevent=( + self.updateSelection, + f"['{group.state_name}', false, fields_filter]", + ), + 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 fields.{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, query): + listing = getattr(self.ctx.setup, group_key) + for item in listing: + if query: + if query.lower() in item.name.lower(): + item.selected = select + else: + item.selected = 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..3e70bfa --- /dev/null +++ b/src/e3sm_siteview/constants.py @@ -0,0 +1,637 @@ +from types import SimpleNamespace + +FIELD_GROUPS = [ + SimpleNamespace( + key="surface", + label="Surface Fields", + icon="mdi-layers-outline", + color="primary", + state_name="variables_2d", + ), + SimpleNamespace( + key="volume", + label="Atmospheric Fields", + icon="mdi-cube-outline", + color="secondary", + state_name="variables_3d", + ), +] + +FIELDS_METADATA = { + "CLDHGH": { + "dim": 2, + "name": "CLDHGH", + "units": "fraction (dimensionless, range [0,1])", + "description": "Vertically-integrated high cloud fraction (random overlap assumption), covering clouds with cloud-top pressure less than 440 hPa", + }, + "CLDLOW": { + "dim": 2, + "name": "CLDLOW", + "units": "fraction", + "description": "Vertically-integrated, random overlap, low cloud amount (cloud top pressure greater than 680 hPa)", + }, + "CLDMED": { + "dim": 2, + "name": "CLDMED", + "units": "fraction", + "description": "Vertically-integrated, random overlap, mid-level cloud amount (cloud top pressure between 440 hPa and 680 hPa)", + }, + "FLNT": { + "dim": 2, + "name": "FLNT", + "units": "W/m²", + "description": "Net longwave flux at top of model", + }, + "FSNT": { + "dim": 2, + "name": "FSNT", + "units": "W/m²", + "description": "Net solar flux at top of model", + }, + "PRECL": { + "dim": 2, + "name": "PRECL", + "units": "m/s", + "description": "Large-scale (stable) precipitation rate (liquid + ice)", + }, + "PRECSC": { + "dim": 2, + "name": "PRECSC", + "units": "m/s", + "description": "Convective snow rate (water equivalent)", + }, + "PRECSL": { + "dim": 2, + "name": "PRECSL", + "units": "m/s", + "description": "Large-scale (stable) snow rate (water equivalent)", + }, + "cnd01_ALL": { + "dim": 2, + "name": "cnd01_ALL", + "units": "", + "description": "Metric used to build the conditional sampling mask for the 'cnd01' (ALL) diagnostic package", + }, + "cnd01_ALL_flag": { + "dim": 2, + "name": "cnd01_ALL_flag", + "units": "", + "description": "Flag marking which columns/levels satisfy the conditional sampling criterion for the 'cnd01' (ALL) diagnostic package", + }, + "NUMICE": { + "dim": 3, + "name": "NUMICE", + "units": "1/kg", + "description": "Grid box averaged cloud ice number concentration", + }, + "NUMLIQ": { + "dim": 3, + "name": "NUMLIQ", + "units": "1/kg", + "description": "Grid box averaged cloud liquid droplet number concentration", + }, + "NUMRAI": { + "dim": 3, + "name": "NUMRAI", + "units": "1/kg", + "description": "Grid box averaged rain number concentration", + }, + "NUMSNO": { + "dim": 3, + "name": "NUMSNO", + "units": "1/kg", + "description": "Grid box averaged snow number concentration", + }, + "RAINQM": { + "dim": 3, + "name": "RAINQM", + "units": "kg/kg", + "description": "Grid box averaged rain mixing ratio", + }, + "RHI": { + "dim": 3, + "name": "RHI", + "units": "%", + "description": "Relative humidity with respect to ice", + }, + "RHW": { + "dim": 3, + "name": "RHW", + "units": "%", + "description": "Relative humidity with respect to liquid water", + }, + "SNOWQM": { + "dim": 3, + "name": "SNOWQM", + "units": "kg/kg", + "description": "Grid box averaged snow mixing ratio", + }, + "cnd01_AIST_ACTDIAG01": { + "dim": 3, + "name": "cnd01_AIST_ACTDIAG01", + "units": "fraction", + "description": "Ice stratiform cloud fraction, sampled at the first aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_AIST_ACTDIAG02": { + "dim": 3, + "name": "cnd01_AIST_ACTDIAG02", + "units": "fraction", + "description": "Ice stratiform cloud fraction, sampled at the second aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_AIST_RAD": { + "dim": 3, + "name": "cnd01_AIST_RAD", + "units": "fraction", + "description": "Ice stratiform cloud fraction, as seen by the radiation scheme, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_ALST_ACTDIAG01": { + "dim": 3, + "name": "cnd01_ALST_ACTDIAG01", + "units": "fraction", + "description": "Liquid stratiform cloud fraction, sampled at the first aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_ALST_ACTDIAG02": { + "dim": 3, + "name": "cnd01_ALST_ACTDIAG02", + "units": "fraction", + "description": "Liquid stratiform cloud fraction, sampled at the second aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_ALST_RAD": { + "dim": 3, + "name": "cnd01_ALST_RAD", + "units": "fraction", + "description": "Liquid stratiform cloud fraction, as seen by the radiation scheme, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_AST_ACTDIAG01": { + "dim": 3, + "name": "cnd01_AST_ACTDIAG01", + "units": "fraction", + "description": "Stratiform cloud fraction (liquid + ice), sampled at the first aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_AST_ACTDIAG02": { + "dim": 3, + "name": "cnd01_AST_ACTDIAG02", + "units": "fraction", + "description": "Stratiform cloud fraction (liquid + ice), sampled at the second aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_AST_RAD": { + "dim": 3, + "name": "cnd01_AST_RAD", + "units": "fraction", + "description": "Stratiform cloud fraction (liquid + ice), as seen by the radiation scheme, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CCN1_ACTDIAG01": { + "dim": 3, + "name": "cnd01_CCN1_ACTDIAG01", + "units": "1/cm³", + "description": "Cloud condensation nuclei concentration at supersaturation bin 1 (~0.02%), sampled at the first aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CCN1_ACTDIAG02": { + "dim": 3, + "name": "cnd01_CCN1_ACTDIAG02", + "units": "1/cm³", + "description": "Cloud condensation nuclei concentration at supersaturation bin 1 (~0.02%), sampled at the second aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CCN1_RAD": { + "dim": 3, + "name": "cnd01_CCN1_RAD", + "units": "1/cm³", + "description": "Cloud condensation nuclei concentration at supersaturation bin 1 (~0.02%), as seen by the radiation scheme, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CCN2_ACTDIAG01": { + "dim": 3, + "name": "cnd01_CCN2_ACTDIAG01", + "units": "1/cm³", + "description": "Cloud condensation nuclei concentration at supersaturation bin 2 (~0.05%), sampled at the first aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CCN2_ACTDIAG02": { + "dim": 3, + "name": "cnd01_CCN2_ACTDIAG02", + "units": "1/cm³", + "description": "Cloud condensation nuclei concentration at supersaturation bin 2 (~0.05%), sampled at the second aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CCN2_RAD": { + "dim": 3, + "name": "cnd01_CCN2_RAD", + "units": "1/cm³", + "description": "Cloud condensation nuclei concentration at supersaturation bin 2 (~0.05%), as seen by the radiation scheme, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CCN3_ACTDIAG01": { + "dim": 3, + "name": "cnd01_CCN3_ACTDIAG01", + "units": "1/cm³", + "description": "Cloud condensation nuclei concentration at supersaturation bin 3 (~0.1%), sampled at the first aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CCN3_ACTDIAG02": { + "dim": 3, + "name": "cnd01_CCN3_ACTDIAG02", + "units": "1/cm³", + "description": "Cloud condensation nuclei concentration at supersaturation bin 3 (~0.1%), sampled at the second aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CCN3_RAD": { + "dim": 3, + "name": "cnd01_CCN3_RAD", + "units": "1/cm³", + "description": "Cloud condensation nuclei concentration at supersaturation bin 3 (~0.1%), as seen by the radiation scheme, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CCN4_ACTDIAG01": { + "dim": 3, + "name": "cnd01_CCN4_ACTDIAG01", + "units": "1/cm³", + "description": "Cloud condensation nuclei concentration at supersaturation bin 4 (~0.2%), sampled at the first aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CCN4_ACTDIAG02": { + "dim": 3, + "name": "cnd01_CCN4_ACTDIAG02", + "units": "1/cm³", + "description": "Cloud condensation nuclei concentration at supersaturation bin 4 (~0.2%), sampled at the second aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CCN4_RAD": { + "dim": 3, + "name": "cnd01_CCN4_RAD", + "units": "1/cm³", + "description": "Cloud condensation nuclei concentration at supersaturation bin 4 (~0.2%), as seen by the radiation scheme, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CCN5_ACTDIAG01": { + "dim": 3, + "name": "cnd01_CCN5_ACTDIAG01", + "units": "1/cm³", + "description": "Cloud condensation nuclei concentration at supersaturation bin 5 (~0.5%), sampled at the first aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CCN5_ACTDIAG02": { + "dim": 3, + "name": "cnd01_CCN5_ACTDIAG02", + "units": "1/cm³", + "description": "Cloud condensation nuclei concentration at supersaturation bin 5 (~0.5%), sampled at the second aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CCN5_RAD": { + "dim": 3, + "name": "cnd01_CCN5_RAD", + "units": "1/cm³", + "description": "Cloud condensation nuclei concentration at supersaturation bin 5 (~0.5%), as seen by the radiation scheme, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CCN6_ACTDIAG01": { + "dim": 3, + "name": "cnd01_CCN6_ACTDIAG01", + "units": "1/cm³", + "description": "Cloud condensation nuclei concentration at supersaturation bin 6 (~1.0%), sampled at the first aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CCN6_ACTDIAG02": { + "dim": 3, + "name": "cnd01_CCN6_ACTDIAG02", + "units": "1/cm³", + "description": "Cloud condensation nuclei concentration at supersaturation bin 6 (~1.0%), sampled at the second aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CCN6_RAD": { + "dim": 3, + "name": "cnd01_CCN6_RAD", + "units": "1/cm³", + "description": "Cloud condensation nuclei concentration at supersaturation bin 6 (~1.0%), as seen by the radiation scheme, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CDMC_ACTDIAG01": { + "dim": 3, + "name": "cnd01_CDMC_ACTDIAG01", + "units": "kg/kg", + "description": "Cloud droplet mass mixing ratio, sampled at the first aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CDMC_ACTDIAG02": { + "dim": 3, + "name": "cnd01_CDMC_ACTDIAG02", + "units": "kg/kg", + "description": "Cloud droplet mass mixing ratio, sampled at the second aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CDMC_RAD": { + "dim": 3, + "name": "cnd01_CDMC_RAD", + "units": "kg/kg", + "description": "Cloud droplet mass mixing ratio, as seen by the radiation scheme, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CDNC_ACTDIAG01": { + "dim": 3, + "name": "cnd01_CDNC_ACTDIAG01", + "units": "1/cm³", + "description": "Cloud droplet number concentration, sampled at the first aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CDNC_ACTDIAG02": { + "dim": 3, + "name": "cnd01_CDNC_ACTDIAG02", + "units": "1/cm³", + "description": "Cloud droplet number concentration, sampled at the second aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CDNC_RAD": { + "dim": 3, + "name": "cnd01_CDNC_RAD", + "units": "1/cm³", + "description": "Cloud droplet number concentration, as seen by the radiation scheme, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CLDICE_ACTDIAG01": { + "dim": 3, + "name": "cnd01_CLDICE_ACTDIAG01", + "units": "kg/kg", + "description": "Grid box averaged cloud ice amount, sampled at the first aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CLDICE_ACTDIAG02": { + "dim": 3, + "name": "cnd01_CLDICE_ACTDIAG02", + "units": "kg/kg", + "description": "Grid box averaged cloud ice amount, sampled at the second aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CLDICE_RAD": { + "dim": 3, + "name": "cnd01_CLDICE_RAD", + "units": "kg/kg", + "description": "Grid box averaged cloud ice amount, as seen by the radiation scheme, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CLDLIQ_ACTDIAG01": { + "dim": 3, + "name": "cnd01_CLDLIQ_ACTDIAG01", + "units": "kg/kg", + "description": "Grid box averaged cloud liquid amount, sampled at the first aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CLDLIQ_ACTDIAG02": { + "dim": 3, + "name": "cnd01_CLDLIQ_ACTDIAG02", + "units": "kg/kg", + "description": "Grid box averaged cloud liquid amount, sampled at the second aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CLDLIQ_RAD": { + "dim": 3, + "name": "cnd01_CLDLIQ_RAD", + "units": "kg/kg", + "description": "Grid box averaged cloud liquid amount, as seen by the radiation scheme, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CONCLD_ACTDIAG01": { + "dim": 3, + "name": "cnd01_CONCLD_ACTDIAG01", + "units": "fraction", + "description": "Convective cloud fraction, sampled at the first aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CONCLD_ACTDIAG02": { + "dim": 3, + "name": "cnd01_CONCLD_ACTDIAG02", + "units": "fraction", + "description": "Convective cloud fraction, sampled at the second aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_CONCLD_RAD": { + "dim": 3, + "name": "cnd01_CONCLD_RAD", + "units": "fraction", + "description": "Convective cloud fraction, as seen by the radiation scheme, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_NUMICE_ACTDIAG01": { + "dim": 3, + "name": "cnd01_NUMICE_ACTDIAG01", + "units": "1/kg", + "description": "Grid box averaged cloud ice number concentration, sampled at the first aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_NUMICE_ACTDIAG02": { + "dim": 3, + "name": "cnd01_NUMICE_ACTDIAG02", + "units": "1/kg", + "description": "Grid box averaged cloud ice number concentration, sampled at the second aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_NUMICE_RAD": { + "dim": 3, + "name": "cnd01_NUMICE_RAD", + "units": "1/kg", + "description": "Grid box averaged cloud ice number concentration, as seen by the radiation scheme, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_NUMLIQ_ACTDIAG01": { + "dim": 3, + "name": "cnd01_NUMLIQ_ACTDIAG01", + "units": "1/kg", + "description": "Grid box averaged cloud liquid droplet number concentration, sampled at the first aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_NUMLIQ_ACTDIAG02": { + "dim": 3, + "name": "cnd01_NUMLIQ_ACTDIAG02", + "units": "1/kg", + "description": "Grid box averaged cloud liquid droplet number concentration, sampled at the second aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_NUMLIQ_RAD": { + "dim": 3, + "name": "cnd01_NUMLIQ_RAD", + "units": "1/kg", + "description": "Grid box averaged cloud liquid droplet number concentration, as seen by the radiation scheme, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_Q_ACTDIAG01": { + "dim": 3, + "name": "cnd01_Q_ACTDIAG01", + "units": "kg/kg", + "description": "Specific humidity, sampled at the first aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_Q_ACTDIAG02": { + "dim": 3, + "name": "cnd01_Q_ACTDIAG02", + "units": "kg/kg", + "description": "Specific humidity, sampled at the second aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_Q_RAD": { + "dim": 3, + "name": "cnd01_Q_RAD", + "units": "kg/kg", + "description": "Specific humidity, as seen by the radiation scheme, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_T_ACTDIAG01": { + "dim": 3, + "name": "cnd01_T_ACTDIAG01", + "units": "K", + "description": "Air temperature, sampled at the first aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_T_ACTDIAG02": { + "dim": 3, + "name": "cnd01_T_ACTDIAG02", + "units": "K", + "description": "Air temperature, sampled at the second aerosol-activation diagnostic call, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "cnd01_T_RAD": { + "dim": 3, + "name": "cnd01_T_RAD", + "units": "K", + "description": "Air temperature, as seen by the radiation scheme, restricted to columns/levels flagged by the 'cnd01' (ALL) conditional sampling mask", + }, + "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/data_models.py b/src/e3sm_siteview/data_models.py new file mode 100644 index 0000000..5a3fc37 --- /dev/null +++ b/src/e3sm_siteview/data_models.py @@ -0,0 +1,141 @@ +import asyncio + +from paraview import simple +from trame.app import asynchronous, dataclass + +from e3sm_siteview.constants import FIELDS_METADATA + + +class Variable(dataclass.StateDataModel): + name = dataclass.Sync(str) + selected = dataclass.Sync(bool, False) + units = dataclass.Sync(str) + description = dataclass.Sync(str) + + @dataclass.watch("name", sync=True, eager=True) + def _on_name_change(self, name): + tokens = name.split("_") + for i in range(1, len(tokens) + 1): + base_name = "_".join(tokens[:i]) + entry = FIELDS_METADATA.get(base_name) + if entry: + self.units = entry.get("units") + self.description = entry.get("description") + return + + print(f"No matching field name for {name}") + + @dataclass.watch("selected", sync=True) + def _on_selected(self, _): + self.server.controller.load_fields() + + +class CloudControls(dataclass.StateDataModel): + show = dataclass.Sync(bool, True) + opacity = dataclass.Sync(float, 0.5) + + +class ColorByControls(dataclass.StateDataModel): + show = dataclass.Sync(bool, True) + color_by = dataclass.Sync(str) + + +class SliceControls(dataclass.StateDataModel): + show = dataclass.Sync(bool, True) + orientation = dataclass.Sync(int, 0) + altitude = dataclass.Sync(float, 0) + altitude_min = dataclass.Sync(float, 0) + altitude_max = dataclass.Sync(float, 100) + + +class FindDataControls(dataclass.StateDataModel): + show = dataclass.Sync(bool, True) + formula = dataclass.Sync(str, "") + + +class ColumnControls(dataclass.StateDataModel): + show = dataclass.Sync(bool, True) + altitude_range = dataclass.Sync(tuple[float, float], (0, 100)) + + +class GlobalParameters(dataclass.StateDataModel): + readers = dataclass.ServerOnly(set, set) + variables_2d = dataclass.Sync(list[Variable], list, has_dataclass=True) + variables_3d = dataclass.Sync(list[Variable], list, has_dataclass=True) + time_values = dataclass.Sync(list[float], list) + time_index = dataclass.Sync(int, 0) + time_index_max = dataclass.Sync(int, 0) + time_value = dataclass.Sync(float, 0.0) + # Visualization + active_viz = dataclass.Sync(list[str], ["surface"]) + time_animating = dataclass.Sync(bool, False) + # Viz controls + cloud = dataclass.Sync(CloudControls, has_dataclass=True) + surface = dataclass.Sync(ColorByControls, has_dataclass=True) + volume = dataclass.Sync(ColorByControls, has_dataclass=True) + slice = dataclass.Sync(SliceControls, has_dataclass=True) + find_data = dataclass.Sync(FindDataControls, has_dataclass=True) + column = dataclass.Sync(ColumnControls, has_dataclass=True) + + def __init__(self, server, **defaults): + super().__init__(server, **defaults) + self.cloud = CloudControls(server) + self.surface = ColorByControls(server) + self.volume = ColorByControls(server) + self.slice = SliceControls(server) + self.find_data = FindDataControls(server) + self.column = ColumnControls(server) + + self.ctrl.load_fields = self.load_fields + self.animation_scene = simple.GetAnimationScene() + + @property + def ctrl(self): + return self.server.controller + + @dataclass.watch("time_values", sync=True) + def _on_time_values(self, values): + self.time_index_max = len(values) - 1 + self.time_value = values[self.time_index] + + @dataclass.watch("time_index", sync=True) + def _on_time_index(self, time_index): + self.time_value = self.time_values[time_index] + self.animation_scene.AnimationTime = self.time_value + self.ctrl.render() + + @dataclass.watch("time_animating") + def _on_animation(self, time_animating): + if time_animating: + asynchronous.create_task(self._animate()) + + async def _animate(self): + while self.time_animating: + await asyncio.sleep(0.1) + if self.time_index < self.time_index_max: + self.time_index += 1 + else: + self.time_index = 0 + self.ctrl.render() + + def register_reader(self, reader): + if len(self.readers) == 0: + reader.UpdatePipelineInformation() + self.time_values = [float(t) for t in reader.TimestepValues] + self.variables_2d = [ + Variable(self.server, name=str(n)) + for n in reader.SurfaceVariables.Available + ] + self.variables_3d = [ + Variable(self.server, name=str(n)) + for n in reader.MiddleLayerVariables.Available + ] + + self.readers.add(reader) + + def load_fields(self): + for reader in self.readers: + reader.SurfaceVariables = [f.name for f in self.variables_2d if f.selected] + reader.MiddleLayerVariables = [ + f.name for f in self.variables_3d if f.selected + ] 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..2d3bb6e --- /dev/null +++ b/src/e3sm_siteview/module/serve/script.js @@ -0,0 +1,12 @@ +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) { + return field.name.toLowerCase()?.includes(query?.toLowerCase() || ""); + }, +}; 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 @@ + + +
+ + +
+{{ requestJson }}
+