Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
6e9709c
First draft, open geo nc file and index file for topo
islas Mar 29, 2025
6ffb45c
Getting source file location for particular latlon in data
islas Mar 31, 2025
6aebd97
Source data reading of geogrid format
islas Apr 15, 2025
1c79c68
Orographic statistics for GWDO
islas Apr 15, 2025
9b38f82
Tile loading helper
islas Apr 17, 2025
dad0851
Switch to using tile loader
islas Apr 17, 2025
c4cb4c1
Account for SW start of data (0,0)
islas Apr 18, 2025
3e9f90b
Use faster evaluation of tile points
islas Apr 18, 2025
abcef4b
Clean up
islas Apr 18, 2025
7c0fa75
Add rudimentary cache clearing
islas Apr 18, 2025
8637148
Suppress debug print statements
islas Apr 18, 2025
0853cd3
Simple driver to process geogrid netCDF output for GWDO
islas Apr 18, 2025
a056911
Fix oa2 calculation for nd and nu
islas May 5, 2025
1149ce6
Flip variable calcs to match comments, output is the same
islas May 5, 2025
402f9bd
Clean up code and add parsing from namelist
islas Oct 13, 2025
8bcaae0
Add MAX_EL
islas Oct 13, 2025
82e1dd4
Remove reuse of mean for older numpy versions
islas Dec 8, 2025
bb555e1
Update calcs to account for odd sized boxes and use mean for orograph…
islas Jan 23, 2026
7a1bbd3
Fix axis flip for oa4
islas Jan 26, 2026
0eacdc0
Code cleanup and revert unused changes
islas Feb 4, 2026
e21ff8c
Change gwdo scaling and awareness handling
islas Mar 24, 2026
a6983f2
Apply scaling of var at correct spot
islas Mar 25, 2026
f4f9d20
Fix typos and adjust wording to be more concise
islas May 29, 2026
9bfd065
Use common Earth radius between this compute_gwdo.py and geogrid
islas May 29, 2026
99270be
Use common self._npts_* where applicable
islas May 29, 2026
f7e42cd
Update help message to reflect accurate usage
islas May 29, 2026
0629d9c
Create new MAX_EL variable with similar attributes to VAR
islas Jun 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions util/OrographyStats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import numpy as np


class OrographyStats:
def __init__( self, box ):
self.box = box

self.mean = np.mean( self.box )
# var (actually stddev)
self.std = np.std( self.box )
self.max = np.max( box )

# This currently does not use the landuse to average over only land and zero
# out on mostly water
# con (convexity)
if self.std < 1.0:
self.con = 0.0
else:
var4 = np.sum( [ ( h - self.mean )**4 for h in np.nditer( box ) ] )
self.con = var4 / ( self.box.size * self.std**4 )

# oa (orographic asymmetry)
self.oa = np.zeros( (4) )
self.calc_oa()

# ol (orographic effective length)
self.ol = np.zeros( (4) )
self.calc_ol()

def __repr__( self ):
return f"Mean : {self.mean} Std : {self.std} Max: {self.max} CON : {self.con} OA : {self.oa} OL : {self.ol}"

def calc_oa( self ):
# Note that right now the assumption is that the box is laid out in col major order
# with [y,x] indices

# oa1 is the orographic asymmetry in the West direction
nu = np.sum( self.box[:, :int((self.box.shape[1] + self.box.shape[1] % 2) / 2)] > self.mean )
nd = np.sum( self.box[:, int((self.box.shape[1] - self.box.shape[1] % 2) / 2):] > self.mean )
self.oa[0] = ( nu - nd ) / ( nu + nd ) if ( ( nu + nd ) > 0 ) else 0.0

# oa2 is the orographic asymmetry in the South direction
nu = np.sum( self.box[:int((self.box.shape[0] + self.box.shape[0] % 2) / 2), :] > self.mean )
nd = np.sum( self.box[int((self.box.shape[0] - self.box.shape[0] % 2) / 2):, :] > self.mean )
self.oa[1] = ( nu - nd ) / ( nu + nd ) if ( ( nu + nd ) > 0 ) else 0.0

# Pre-compute the geometric diagonal of the box
slope = self.box.shape[0] / self.box.shape[1]
j, i = np.indices( self.box.shape )
# Corrected - all indices that lie on diagonal are counted in both upstream and downstream
vals = ( i + 1 ) * slope - ( self.box.shape[0] - j )
upstream = ( vals <= slope )
downstream = ( vals >= -1.0 )

# MPAS calcs - slightly accounts for diagonal when i ~= j but increasingly off for larger discrepancies
# vals = np.rint( ( i + 1 ) * slope ) - ( self.box.shape[0] - j )
# upstream = ( vals <= 0 )
# downstream = ( vals >= 0 )

# oa3 is the orographic asymmetry in the South-West direction
nu = np.sum( self.box[upstream] > self.mean )
nd = np.sum( self.box[downstream] > self.mean )
self.oa[2] = ( nu - nd ) / ( nu + nd ) if ( ( nu + nd ) > 0 ) else 0.0

# oa4 is the orographic asymmetry in the North-West direction
upstream = np.flip( upstream, axis=0 )
downstream = np.flip( downstream, axis=0 )
nu = np.sum( self.box[upstream] > self.mean )
nd = np.sum( self.box[downstream] > self.mean )
self.oa[3] = ( nu - nd ) / ( nu + nd ) if ( ( nu + nd ) > 0 ) else 0.0

def calc_ol( self ):
# ol1 is the effective orographic length in the West direction
interior = self.box[int(np.floor(self.box.shape[0] * .25)):int(np.ceil(self.box.shape[0] * .75)), :]
self.ol[0] = np.sum( interior > self.mean ) / interior.size

# ol2 is the effective orographic length in the South direction
interior = self.box[:, int(np.floor(self.box.shape[1] * .25)):int(np.ceil(self.box.shape[1] * .75))]
self.ol[1] = np.sum( interior > self.mean ) / interior.size

# The prescribed methodology uses 4 quadrants to get the diagonals and effectively
# test half of the box, however this does not actually test the interior half
# of the area of the box in the wind direction...

# ol3 is the effective orographic length in the South-West direction
interiorA = self.box[:int(self.box.shape[0] / 2), :int(self.box.shape[1] / 2)] # first half of x first half of y
interiorB = self.box[int(self.box.shape[0] / 2):, int(self.box.shape[1] / 2):] # second half of x second half of y

self.ol[2] = ( np.sum( interiorA > self.mean ) + np.sum( interiorB > self.mean ) ) / ( interiorA.size + interiorB.size )

# ol4 is the effective orographic length in the North-West direction
interiorA = self.box[int(self.box.shape[0] / 2):, :int(self.box.shape[1] / 2)] # first half of x second half of y
interiorB = self.box[:int(self.box.shape[0] / 2), int(self.box.shape[1] / 2):] # second half of x first half of y

self.ol[3] = ( np.sum( interiorA > self.mean ) + np.sum( interiorB > self.mean ) ) / ( interiorA.size + interiorB.size )
258 changes: 258 additions & 0 deletions util/SourceData.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import numpy as np
import cartopy

from TileData import TileData


def get_kv_pair( line, delimiter="=", comment="#" ):
non_comment = line.split( comment, maxsplit=1 )[0]

if delimiter in non_comment:
key, _, value = non_comment.partition( delimiter )
return key.strip(), value.strip()
else:
return None, None


class IndexData:
def __init__( self, path ):
self.source_path = path
# Default values of index file from :
# https://www2.mmm.ucar.edu/wrf/users/wrf_users_guide/build/html/wps.html#index-options
self.projection = "required"
self.source_type = "required"
self.signed = False
self.units = "required"
self.description = "required"
self.dx = -1.0 # required
self.dy = -1.0 # required
self.known_x = 1.0
self.known_y = 1.0
self.known_lat = -1.0 # required
self.known_lon = -1.0 # required
self.stdlon = -2.0 # not required
self.truelat1 = -2.0 # not required
self.truelat2 = -2.0 # not required
self.wordsize = -1 # required
self.tile_x = -1 # required
self.tile_y = -1 # required
self.tile_z = -2 # not required
self.tile_z_start = -2 # not required
self.tile_z_end = -2 # not required
self.category_min = -2 # not required
self.category_max = -2 # not required
self.tile_bdr = 0
self.missing_value = -2.0 # not required
self.scale_factor = 1.0
self.row_order = "bottom_top"
self.endian = "big"
self.iswater = 16
self.islake = -1 # i.e. no separate inland water category (does not mean required)
self.isice = 24
self.isurban = 1
self.isoilwater = 14
self.mminlu = "USGS"
self.filename_digits = 5

self.read_file()

def read_file( self ):
with open( self.source_path + "/index" ) as f:
for line in f:
key, value = get_kv_pair( line )
if key is not None:
if key == "projection": self.projection = value
elif key == "source_type": self.source_type = value
elif key == "signed": self.signed = ( value == "yes" )
elif key == "units": self.units = value
elif key == "description": self.description = value
elif key == "dx": self.dx = float( value )
elif key == "dy": self.dy = float( value )
elif key == "known_x": self.known_x = float( value )
elif key == "known_y": self.known_y = float( value )
elif key == "known_lat": self.known_lat = float( value )
elif key == "known_lon": self.known_lon = float( value )
elif key == "stdlon": self.stdlon = float( value )
elif key == "truelat1": self.truelat1 = float( value )
elif key == "truelat2": self.truelat2 = float( value )
elif key == "wordsize": self.wordsize = int( value )
elif key == "tile_x": self.tile_x = int( value )
elif key == "tile_y": self.tile_y = int( value )
elif key == "tile_z": self.tile_z = int( value )
elif key == "tile_z_start": self.tile_z_start = int( value )
elif key == "tile_z_end": self.tile_z_end = int( value )
elif key == "category_min": self.category_min = int( value )
elif key == "category_max": self.category_max = int( value )
elif key == "tile_bdr": self.tile_bdr = int( value )
elif key == "missing_value": self.missing_value = float( value )
elif key == "scale_factor": self.scale_factor = float( value )
elif key == "row_order": self.row_order = value
elif key == "endian": self.endian = value
elif key == "iswater": self.iswater = int( value )
elif key == "islake": self.islake = int( value )
elif key == "isice": self.isice = int( value )
elif key == "isurban": self.isurban = int( value )
elif key == "isoilwater": self.isoilwater = int( value )
elif key == "mminlu": self.mminlu = value
elif key == "filename_digits": self.filename_digits = int( value )

def get_dtype( self ):
endian = ">" if self.endian == "big" else "<"
signed = "i" if self.signed else "u"
return f"{endian}{signed}{self.wordsize}"


class SourceData:
def __init__( self, name, path ):
self._earth_radius = 6370000.0 # Use the same earth radius as used in geogrid
self._name = name
self._source_path = path
self._index = IndexData( self._source_path )

self._projection = None
self._npts_x = int( 360.0 / self._index.dx )
self._npts_y = int( 180.0 / self._index.dy )
self._pts_per_deg = int( 1.0 / self._index.dx )
self._subgrid_m_dx = 2.0 * np.pi * self._earth_radius / self._npts_x

self._tile_data = TileData(
int( self._npts_x / self._index.tile_x ),
int( self._npts_y / self._index.tile_y ),
self._index.tile_x,
self._index.tile_y,
load_func=lambda i, j:
self.read_geogrid(
# Limit the view to just the tile data for now, remove border
self.get_tile_name_ij( i, j )[0] )[
0,
self._index.tile_bdr:self._index.tile_y + self._index.tile_bdr,
self._index.tile_bdr:self._index.tile_x + self._index.tile_bdr
]
)

def read_geogrid( self, file ):
"""
Read in the geogrid raw data using the format provided here:
https://www2.mmm.ucar.edu/wrf/users/wrf_users_guide/build/html/wps.html#writing-static-data-to-the-geogrid-binary-format

Note that data passed out is assumed to be oriented as follows:
SW
<- -x +x -> (increasing longitude)
^ +---+---+---+---+---+---+
| | | | | | | |
-y +---+---+---+---+---+---+
| | | | | | |
+---+---+---+---+---+---+
| | | | | | |
+---+---+---+---+---+---+
| | | | | | |
+y +---+---+---+---+---+---+
| | | | | | | |
v +---+---+---+---+---+---+
(increasing latitude)
NE
"""
rawdata = np.fromfile(
file,
dtype=self._index.get_dtype()
)
data = rawdata.astype( np.float32, casting="unsafe" ) * self._index.scale_factor
z_dim = 1
if self._index.tile_z_start > 0 and self._index.tile_z_end > 0:
z_dim = self._index.tile_z_end - self._index.tile_z_start
elif self._index.tile_z > 0:
z_dim = self._index.tile_z

data = data.reshape(
(
z_dim,
self._index.tile_y + 2 * self._index.tile_bdr,
self._index.tile_x + 2 * self._index.tile_bdr
)
)
if self._index.row_order == "top_bottom":
data = np.flip( data, axis=1 )

return data

def latlon_to_ij( self, lat, lon ):
# Ignore staggering for now
i = 0.0
j = 0.0
if self._index.projection == "regular_ll":
delta_lat = lat - self._index.known_lat
delta_lon = lon - self._index.known_lon

i = ( delta_lon / self._index.dx + self._index.known_x ) % self._npts_x
j = ( delta_lat / self._index.dy + self._index.known_y )

return i, j

def ij_to_latlon( self, i, j ):
lat = 0.0
lon = 0.0
if self._index.projection == "regular_ll":
lon = ( i - self._index.known_x ) * self._index.dx + self._index.known_lon
lat = ( j - self._index.known_y ) * self._index.dy + self._index.known_lat

if lon > 180.0:
lon -= 360.0

return lat, lon

def get_tile_extent( self, starti, startj ):
start_lat, start_lon = self.ij_to_latlon( starti - self._index.tile_bdr, startj - self._index.tile_bdr )
stop_lat, stop_lon = self.ij_to_latlon( starti + self._index.tile_x - 1 + self._index.tile_bdr, startj + self._index.tile_y - 1 + self._index.tile_bdr )
print( ( starti - self._index.tile_bdr, startj - self._index.tile_bdr ) )
print( ( starti + self._index.tile_x - 1 + self._index.tile_bdr, startj + self._index.tile_y - 1 + self._index.tile_bdr ) )
return ( start_lon, stop_lon, start_lat, stop_lat )

def get_tile_name_ll( self, lat, lon ):
i, j = self.latlon_to_ij( lat, lon )
return self.get_tile_name_ij( i, j )

def get_tile_name_ij( self, i, j ):
tile_i = self._index.tile_x * int( int( i ) / self._index.tile_x ) + 1
tile_j = self._index.tile_y * int( int( j ) / self._index.tile_y ) + 1

path = f"{self._source_path}/"
path += f"{{xstart:0{self._index.filename_digits}d}}-{{xstop:0{self._index.filename_digits}d}}."
path += f"{{ystart:0{self._index.filename_digits}d}}-{{ystop:0{self._index.filename_digits}d}}"
return path.format(
xstart=tile_i,
xstop=tile_i + self._index.tile_x - 1,
ystart=tile_j,
ystop=tile_j + self._index.tile_y - 1
), tile_i, tile_j

def get_box( self, lat, lon, size_x, size_y=None ):
if size_y is None:
size_y = size_x

# X span in points
nx = 0
if (
np.cos( np.deg2rad( lat ) )
> ( 2.0 * self._pts_per_deg * size_x * 180.0 ) / ( self._npts_x * np.pi * self._earth_radius )
):
nx = int(
np.ceil(
( 180.0 * size_x * self._pts_per_deg )
/ ( np.pi * self._earth_radius * np.cos( np.deg2rad( lat ) ) )
)
)
else:
nx = int( self._npts_x / 2 )

# Y span in points
ny = int( np.ceil( ( 180.0 * size_y * self._pts_per_deg ) / ( np.pi * self._earth_radius ) ) )

true_i, true_j = self.latlon_to_ij( lat, lon )
# Generate the indices for this box regardless of tile periodicity, let the tile data handle that
indices = np.indices( ( ny, nx ) )
indices[0] += int( true_j - ny / 2 )
indices[1] += int( true_i - nx / 2 )

box = self._tile_data.get_box( indices )
# box is now a 2D box of data spanning size in meters centered on lat/lon
return box
Loading