SCHISM

Executive Summary

Probably best encapsulated by the authors: “SCHISM (Semi-implicit Cross-scale Hydroscience Integrated System Model) is an open-source community-supported modeling system based on unstructured grids, designed for seamless simulation of 3D baroclinic circulation across creek-lake-river-estuary-shelf-ocean scales. It uses a highly efficient and accurate semi-implicit finite-element/finite-volume method with Eulerian-Lagrangian algorithm to solve the Navier-Stokes equations (in hydrostatic form)…”. This model forms the core of OWP’s total water level prediction and has been deployed at scale across a number of domains. (SCHISM Manual at Ccrm-Vims-Edu, n.d.)

Tutorials

Looking at interpolation differences

Pulling the maximum forecast (the easy way)

library(magrittr)

NOMADS_from_nwmTools <- nwmTools::get_nomads_urls(
  output = 'total_water',
  domain = 'atlgulf',
  num = 18,
  version = 'v3.0',
  outdir = ".") %>%
  nwmTools::get_timeseries(
    index_id = c('SCHISM_hgrid_node_x', 'SCHISM_hgrid_node_y'),
    varname  = 'elevation'
  )
  
NOMADS_from_nwmTools$max <- do.call(pmax, c(NOMADS_from_nwmTools[,3:ncol(NOMADS_from_nwmTools)], list(na.rm=TRUE)))
schism_df_sf <- sf::st_as_sf(NOMADS_from_nwmTools, coords=c("SCHISM_hgrid_node_x","SCHISM_hgrid_node_y")) |>
  sf::st_set_crs(sf::st_crs("EPSG:4326"))

Pulling the maximum forecast (the hard way)

Sometimes nwmTools might not work as expected, requiring us to perform that data manipulation manually. While this isn’t ideal, I’ve found it to be an occasionally valuable exercise; much like doing math by hand in order to fully grasp the concepts before using a calculator, a foundational understanding of the underlying processes is critical for that next step in explainability. Starting with an understanding of the forecast and releases cycle will help you predict the right timestep to look at, but you can also just try and guess at what you want by clicking around the NOMDAS server. Here we’ll pull in the short range forecast which we know has 18 timesteps, and we’ll brute force our way into the right time with a few handles, and match the format that nwmTools generates because we haven’t already made our lives hard enough.

outpath <- file.path("~/data/output/schism_forecast")
unlink(outpath)
is_quiet <- FALSE

## Get the timestamp for this run
first_valid_timestamp <- format(Sys.time(), tz = "GMT", format = "%Y-%m-%d %H:%M:%S") 
first_valid_date <- gsub("-", "",format(Sys.time(), tz = "GMT", format = "%Y-%m-%d"))
first_valid_hour <- format(Sys.time(), tz = "GMT", format = "%H")


# -- Check to make sure I have the right date --------------------------------------------------------------------------- 
testurl <- paste0('https://nomads.ncep.noaa.gov/pub/data/nccf/com/nwm/v3.0/nwm.', first_valid_date, '/short_range_coastal_atlgulf')
if (httr::http_error(testurl)) { 
  first_valid_date <- gsub("-", "",format(as.Date(first_valid_timestamp, tz = "GMT")-1, format = "%Y-%m-%d"))
}

# -- Guess at the most recent time ---------------------------------------------------------------------------------------
first_valid_hour <- 25
repeat {
  first_valid_hour = first_valid_hour-1
  run_time <- sprintf("%02d", first_valid_hour)
  testurl <- paste0('https://nomads.ncep.noaa.gov/pub/data/nccf/com/nwm/v3.0/nwm.', first_valid_date,'/short_range_coastal_atlgulf/nwm.t', run_time, 'z.short_range_coastal.total_water.f018.atlgulf.nc')
  if(!httr::http_error(testurl)) {
    break
  }
}

first_nomads_timestamp <- strptime(paste0(first_valid_date," ",run_time,':00:00'), format ="%Y%m%d %H:%M:%S",tz = "UTC")
for (i in sprintf("%03d",c(1:18))) {
  # i = sprintf("%03d",c(1:18))[1]
  url <- glue::glue("http://nomads.ncep.noaa.gov/pub/data/nccf/com/nwm/v3.0/nwm.{first_valid_date}/short_range_coastal_atlgulf/nwm.t{run_time}z.short_range_coastal.total_water.f{i}.atlgulf.nc")
  download.file(url, file.path(outpath,"forecast",basename(url),fsep = .Platform$file.sep))
}
all_nc_timesteps <- list.files(file.path(outpath,"forecast",fsep = .Platform$file.sep),pattern = '*.nc$',full.names = TRUE) |> gtools::mixedsort()

nc_file <- ncdf4::nc_open(all_nc_timesteps[1])
x <- ncdf4::ncvar_get(nc_file,"SCHISM_hgrid_node_x")
y <- ncdf4::ncvar_get(nc_file,"SCHISM_hgrid_node_y")
z <- ncdf4::ncvar_get(nc_file,"elevation")

varname <- glue::glue('elevation_{sub("_"," ",ncdf4::ncatt_get(nc_file,0,attname="model_output_valid_time")$value)}')
NOMADS_from_files <- data.frame("SCHISM_hgrid_node_x" = x,
                                "SCHISM_hgrid_node_y" = y,
                                varname = z)
names(NOMADS_from_files)[names(NOMADS_from_files) == "varname"] <- glue::glue('elevation_{sub("_"," ",ncdf4::ncatt_get(nc_file,0,attname="model_output_valid_time")$value)}')
ncdf4::nc_close(nc_file)

j <- length(all_nc_timesteps)
for(i in 2:j) {
  if(!is_quiet) { message(glue::glue("Folding in {i} of {j}")) }
  nc_file <- ncdf4::nc_open(all_nc_timesteps[i])
  NOMADS_from_files$varname <- ncdf4::ncvar_get(nc_file,"elevation")
  names(NOMADS_from_files)[names(NOMADS_from_files) == "varname"] <- glue::glue('elevation_{sub("_"," ",ncdf4::ncatt_get(nc_file,0,attname="model_output_valid_time")$value)}')
  ncdf4::nc_close(nc_file)
}

NOMADS_from_files$max <- do.call(pmax, c(NOMADS_from_files[,3:ncol(NOMADS_from_files)], list(na.rm=TRUE)))

## And the spatial object:
schism_df_sf <- sf::st_as_sf(NOMADS_from_files, coords=c("SCHISM_hgrid_node_x","SCHISM_hgrid_node_y")) |>
  sf::st_set_crs(sf::st_crs("EPSG:4326"))

A few examples of mapping meshes

There are several places to grab mesh elevations but the most authoritative is here:(https://www.nohrsc.noaa.gov/owp_files/nwm/nwm_parameters/README.v3.0.txt). There are a few files we can hunt down to find any differences.

  • elev.ic: A .gr3 format file that specifies the initial elevation at each node.
  • hgrid.gr3 and hgrid.ll: Horizontal grid file with node centered spatial data and mesh connectivity.
  • hgrid.nc: grid file in netcdf format, containing a list of nodes with their locations and elevations along with a list of elements.
  • hgrid.vtk: ASCII version of the grid file containing element number, element coordinates, and element original coordinates.
  • And NOMADS short range outputs.

Of those, elev.ic seems inappropriate as it represents a “hot” start file since the average node elevation across the database is > 5? hgrid.nc is oddly formatted (as virtually every netcdf file seems to be), but more concretely I’m not seeing an elevation field. Regardless, we can still use the XY as a data point to sanity check. hgrid.vtk is aspatial/unscaled, let’s skip that headache for the time being. That gives us the following:

[1] "Are coordiantes identical?"
identical(hgrid.gr3, hgrid.ll):TRUE
identical(hgrid.gr3, hgrid.nc):FALSE
File.testing Number.of.nodes Mean.Elevation
elev.ic 10537609 5.735372
hgrid.gr3 10537609 -4.853219
hgrid.ll 10537609 -4.853219
hgrid.nc 10537609 NA
NOMADS 10481055 NA

Just to make sure we’re not going insane, let’s take a look at these nodes along the U.S.’s shortest seacoast, New Hampshire!

Loading basemap 'world_imagery' from map service 'esri'...

Yup, we’ve lost it.

Mapping a mesh

As a finite element model, SCHISM’s numerical schema is constructed from nodes and the polygons that those nodes define between them. While it’s not typically needed to regenerate the polygons themselves since the model has given us an explicit forecast at the nodes, having access to those shapes is useful. For SCHISM, these are stored in the hgrid.gr3 file, which encodes the node centered spatial data and mesh connectivity as guessable text we can parse out like so:

Hidden

SCHISM Style Inundation

Reference

SCHISM Site: https://ccrm.vims.edu/schismweb/
Online docs: https://schism-dev.github.io/schism/master/index.html

Alternative coastal flooding FIM solutions

NOT comprehensive

The CoastalFlood interface

_4f0f5d367d165e631e24a6f305780402.png

The Eastern Region Headquarters publishes the CoastalFlood service. This service takes the more familiar observation system, normalizes gage datum, and applies a linear interpolation across the various stage impact levels in order to mimic predicted impacts of a sea level forecast to impacts on the landscape. Although a far courser than the SCHISM outputs, this familiarity with both data source and methodology can provide a handy first data point in your decision making process.

Explanations

Versions of SCHISM

Small alterations to the mesh occurred between 3.0 and 3.1, but the impact of that alteration was buried deeper in the modeling process. If we pull the forecasts and map the different versions like so:

Node counts

version total_nodes
30 10481055
31 10481055
para 10481055

Difference map

Figure 1

Conclusions:

Some regridding/mapping occurs behind the scenes to keep node count consistent?

Coastal FIM

One of the neatest aspects of the total water level forecast is that it’s an explicit prediction of space that can be mapped and evaluated with a “ground truth”. In practice the distinction between modeling surface and mapping surface is lost. To create that surface, we can run a variety of interpolation algorithms. Due to the scale of the SCHISM prediction (continental) and the scale of mapping (building level), an efficient interpolation method is needed in order to meet the needs and concerns of operational flood mapping. Because as we see above, the nodes are close enough that a linear interpolation is beyond sufficient, we’ll precompute depth weights for the mapping surface and deploy a package that performs all the needed steps. See the Coastal-FIM repository for more details.

Make the weights grid we first need to transform our prediction points to the requisite shapefile format. I’ll demo a CoastalFlood product based on gages and flood thresholds.

```{python}

```
```{python}
> test <- sf::st_read("~/data/raw/lynker-spatial/coastal_fim/Processing_files/shape_files_folder/ag_ElementsNodes/ag_ElementsNodes.shp")
Reading layer `ag_ElementsNodes' from data source `/home/rstudio/data/raw/lynker-spatial/coastal_fim/Processing_files/shape_files_folder/ag_ElementsNodes/ag_ElementsNodes.shp' using driver `ESRI Shapefile'
Simple feature collection with 20967960 features and 4 fields
Geometry type: POLYGON
Dimension:     XYZ
Bounding box:  xmin: -98.0036 ymin: 24.46769 xmax: -66.78119 ymax: 45.24413
z_range:       zmin: -132.3227 zmax: 462.3862
Geodetic CRS:  WGS 84
> test
Simple feature collection with 20967960 features and 4 fields
Geometry type: POLYGON
Dimension:     XYZ
Bounding box:  xmin: -98.0036 ymin: 24.46769 xmax: -66.78119 ymax: 45.24413
z_range:       zmin: -132.3227 zmax: 462.3862
Geodetic CRS:  WGS 84
First 10 features:
   elementID node1 node2 node3                       geometry
1          1  7502  7501 14880 POLYGON Z ((-74.03291 40.61...
2          2 14880  7501 14879 POLYGON Z ((-74.03203 40.61...
3          3 14881  7502 14880 POLYGON Z ((-74.0325 40.614...
4          4 14879  7500 14878 POLYGON Z ((-74.03152 40.61...
5          5 29516 29517 22274 POLYGON Z ((-74.03075 40.61...
6          6 22273 22274 14880 POLYGON Z ((-74.03111 40.61...
7          7 14879 22273 14880 POLYGON Z ((-74.03152 40.61...
8          8 14879  7501  7500 POLYGON Z ((-74.03152 40.61...
9          9 14881 14880 22274 POLYGON Z ((-74.0325 40.614...
10        10  7503 14881 14882 POLYGON Z ((-74.03338 40.61..
```

Then make the mask…

```{python}
python zonal_fim.py 
--generate_mask True 
--preprocess False 
--generate_wse False 
--generate_depth False 
--zarr_format False  
--execute False  
--dissolve True 
-k 'G:\data\raw\lynker-spatial\coastal_fim\Processing_files\shape_files_folder\ag_ElementsNodes\ag_ElementsNodes.zip' 
-l '/path/output_elements_folder' 
-a '/path/masks.duckdb' 
-p 'G:\data\raw\lynker-spatial\coastal_fim\Processing_files\shape_files_folder\ag_ElementsNodes\exterior_mask_schism_boundary_atlantic_buffer_atlgulf' 
-n 'G:\data\raw\lynker-spatial\coastal_fim\Processing_files\shape_files_folder\ag_ElementsNodes\exterior_mask_state_boundaries_conus_atlgulf' 
-x 'G:\data\raw\lynker-spatial\coastal_fim\Processing_files\shape_files_folder\ag_ElementsNodes\interior_mask_levee_protected_area_conus_atlgulf' 
-t 'G:\data\raw\lynker-spatial\coastal_fim\Processing_files\shape_files_folder\ag_ElementsNodes\interior_mask_nwm_lakes_conus_atlgulf' 
-f 'G:\data\raw\lynker-spatial\coastal_fim\Processing_files\shape_files_folder\ag_ElementsNodes\interior_mask_water_polygon_conus_atlgulf' 
```
```{r}
# R ocean Mask ------------------------------------------------------------
# From: https://github.com/lynker-spatial/zonal-fim/blob/main/preprocesing/README.md
LS_path <- "C:/Users/james.coll/Desktop/root/data/raw/lynker-spatial"
schism_bounds <- file.path(LS_path,"coastal_fim/Processing_files/shape_file_folder/atlgulf/atlgulf",
                           "exterior_mask_schism_boundary_atlantic/mask_schism_boundary_atlantic.shp",fsep=.Platform$file.sep) |> sf::st_read() |> sf::st_make_valid()
state_bounds <- file.path(LS_path,"coastal_fim/Processing_files/shape_file_folder/atlgul/atlgulf",
                          "exterior_mask_state_boundaries_conus/mask_state_boundaries_conus.shp",fsep=.Platform$file.sep) |> sf::st_read() |> sf::st_make_valid()
levee_bounds <- file.path(LS_path,"coastal_fim/Processing_files/shape_file_folder/atlgulf/atlgulf",
                          "interior_mask_levee_protected_area_conus/mask_levee_protected_area_conus.shp",fsep=.Platform$file.sep) |>   sf::st_read() |> sf::st_make_valid()
lakes_bounds <- file.path(LS_path,"coastal_fim/Processing_files/shape_file_folder/atlgulf/atlgulf",
                          "interior_mask_nwm_lakes_conus/mask_nwm_lakes_conus.shp",fsep=.Platform$file.sep) |> sf::st_read() |> sf::st_make_valid()
water_bounds <- file.path(LS_path,"coastal_fim/Processing_files/shape_file_folder/atlgulf/atlgulf",
                          "interior_mask_water_polygon_conus/mask_water_polygon_conus.shp",fsep=.Platform$file.sep) |> sf::st_read() |> sf::st_make_valid()
# ME <- sf::st_union(schism_bounds,state_bounds, by_feature = FALSE,is_coverage = TRUE) |> sf::st_combine()
# ggplot2::ggplot(sf::st_combine(ME)) + ggplot2::geom_sf(fill = "grey")

```

Then construct the database.

```{python}
from cfimvis.tools.format_data import exctract_mask
exctract_mask(mask_database_path='/path_to_mask_database/masks.duckdb', output_folder_path='output_folder_path')
```
```{r}
pacman::p_load(
  hydrofabric,
  AOI,      # For handling areas of interest
  raster,   # Needed for fasterize
  fasterize # For fast rasterization of vector data
)

# Define file paths:
# > In this directory, you need the CoastalBathy Grid, the bary_triangles.gpkg
base <- '/Users/mikejohnson/hydrofabric/coastal-fim'


gpkg <- glue('{base}/ElementPolygons.gpkg')
weights_file <- glue('{base}/agElementPolygons.parquet')
d <- rast(glue('{base}/TBDEM_AtlanticGulf_Mosaic_NWM_3_Revised_v4_COG_4326.tif'))
mask_file <- glue("{base}/mask.tif")

# Read and reproject vector data
gpkg_layer <- 'ElementPolygons'
pg5070 <- st_transform(read_sf(gpkg, gpkg_layer), 5070)

# Create a grid based on the bounding box of the input geometry
tess <- bbox_get(pg5070) |> 
  st_make_grid() |> 
  st_as_sf()

# Compute the convex hull of the input geometry
hull <- st_as_sf(st_union(pg5070))

# Filter grid cells intersecting the hull
tess_small <- st_filter(tess, hull)

# Initialize a list to store weighted grid results
ll <- list()

# Iterate through each grid cell and compute weights
for (i in 1:nrow(tess_small)) {
  tmp <- st_filter(pg5070, tess_small[i, ])
  
  message("There are ", 
          nrow(tmp), 
          " features in tesselation ", 
          i, 
          "/", 
          nrow(tess_small))
  
  ll[[i]] <- weight_grid(d, geom = tmp, ID = 'pg_id')
}

# Masking out the ocean
if(!file.exists(mask_file)) {
  mask <- st_zm(read_sf(gpkg, "mask"))
  
  f    <- fasterize(mask, raster(d))
  
  writeRaster(f, mask_file, overwrite = TRUE)
}

# Combine elevation data with mask values
tab <- data.frame(
  elevation = values(d),
  mask = values(rast(mask_file)),
  cell = 1:ncell(d)) |> 
  setNames(c("elevation", 'mask', "cell"))

# Combine all grid results and join with elevation/mask data
fin <-  left_join(bind_rows(ll), tab, by = 'cell')

# Preview the final dataset
head(fin)

# Save the final dataset to a Parquet file
unlink(weights_file)
write_parquet(fin, weights_file)
```

References

SCHISM Manual at Ccrm-Vims-Edu. n.d. Https://ccrm.vims.edu/schismweb/.