flopy/flopy/export/netcdf.py

1513 lines
51 KiB
Python

import os
import platform
import socket
import copy
import json
import numpy as np
from datetime import datetime
import time
from .metadata import acdd
import flopy
# globals
FILLVALUE = -99999.9
ITMUNI = {
0: "undefined",
1: "seconds",
2: "minutes",
3: "hours",
4: "days",
5: "years",
}
PRECISION_STRS = ["f4", "f8", "i4"]
STANDARD_VARS = ["longitude", "latitude", "layer", "elevation", "time"]
path = os.path.split(__file__)[0]
with open(path + "/longnames.json") as f:
NC_LONG_NAMES = json.load(f)
class Logger(object):
"""
Basic class for logging events during the linear analysis calculations
if filename is passed, then an file handle is opened
Parameters
----------
filename : bool or string
if string, it is the log file to write. If a bool, then log is
written to the screen. echo (bool): a flag to force screen output
Attributes
----------
items : dict
tracks when something is started. If a log entry is
not in items, then it is treated as a new entry with the string
being the key and the datetime as the value. If a log entry is
in items, then the end time and delta time are written and
the item is popped from the keys
"""
def __init__(self, filename, echo=False):
self.items = {}
self.echo = bool(echo)
if filename == True:
self.echo = True
self.filename = None
elif filename:
self.f = open(filename, "w", 0) # unbuffered
self.t = datetime.now()
self.log("opening " + str(filename) + " for logging")
else:
self.filename = None
def log(self, phrase):
"""
log something that happened
Parameters
----------
phrase : str
the thing that happened
"""
pass
t = datetime.now()
if phrase in self.items.keys():
s = (
str(t)
+ " finished: "
+ str(phrase)
+ ", took: "
+ str(t - self.items[phrase])
+ "\n"
)
if self.echo:
print(
s,
)
if self.filename:
self.f.write(s)
self.items.pop(phrase)
else:
s = str(t) + " starting: " + str(phrase) + "\n"
if self.echo:
print(
s,
)
if self.filename:
self.f.write(s)
self.items[phrase] = copy.deepcopy(t)
def warn(self, message):
"""
Write a warning to the log file
Parameters
----------
message : str
the warning text
"""
s = str(datetime.now()) + " WARNING: " + message + "\n"
if self.echo:
print(
s,
)
if self.filename:
self.f.write(s)
return
class NetCdf(object):
"""
Support for writing a netCDF4 compliant file from a flopy model
Parameters
----------
output_filename : str
Name of the .nc file to write
model : flopy model instance
time_values : the entries for the time dimension
if not None, the constructor will initialize
the file. If None, the perlen array of ModflowDis
will be used
z_positive : str ('up' or 'down')
Positive direction of vertical coordinates written to NetCDF file.
(default 'down')
verbose : if True, stdout is verbose. If str, then a log file
is written to the verbose file
forgive : what to do if a duplicate variable name is being created. If
True, then the newly requested var is skipped. If False, then
an exception is raised.
**kwargs : keyword arguments
modelgrid : flopy.discretization.Grid instance
user supplied model grid which will be used in lieu of the model
object modelgrid for netcdf production
Notes
-----
This class relies heavily on the grid and modeltime objects,
including these attributes: lenuni, itmuni, start_datetime, and proj4.
Make sure these attributes have meaningful values.
"""
def __init__(
self,
output_filename,
model,
time_values=None,
z_positive="up",
verbose=None,
prj=None,
logger=None,
forgive=False,
**kwargs
):
assert output_filename.lower().endswith(".nc")
if verbose is None:
verbose = model.verbose
if logger is not None:
self.logger = logger
else:
self.logger = Logger(verbose)
self.var_attr_dict = {}
self.log = self.logger.log
if os.path.exists(output_filename):
self.logger.warn("removing existing nc file: " + output_filename)
os.remove(output_filename)
self.output_filename = output_filename
self.forgive = bool(forgive)
self.model = model
self.model_grid = model.modelgrid
if "modelgrid" in kwargs:
self.model_grid = kwargs.pop("modelgrid")
self.model_time = model.modeltime
if prj is not None:
self.model_grid.proj4 = prj
if self.model_grid.grid_type == "structured":
self.dimension_names = ("layer", "y", "x")
STANDARD_VARS.extend(["delc", "delr"])
# elif self.model_grid.grid_type == 'vertex':
# self.dimension_names = ('layer', 'ncpl')
else:
raise Exception(
"Grid type {} not supported.".format(self.model_grid.grid_type)
)
self.shape = self.model_grid.shape
try:
import dateutil.parser
except:
print(
"python-dateutil is not installed\n"
+ "try pip install python-dateutil"
)
return
self.start_datetime = self._dt_str(
dateutil.parser.parse(self.model_time.start_datetime)
)
self.logger.warn("start datetime:{0}".format(str(self.start_datetime)))
proj4_str = self.model_grid.proj4
if proj4_str is None:
proj4_str = "epsg:4326"
self.log(
"Warning: model has no coordinate reference system specified. "
"Using default proj4 string: {}".format(proj4_str)
)
self.proj4_str = proj4_str
self.grid_units = self.model_grid.units
self.z_positive = z_positive
if self.grid_units is None:
self.grid_units = "undefined"
assert self.grid_units in ["feet", "meters", "undefined"], (
"unsupported length units: " + self.grid_units
)
self.time_units = self.model_time.time_units
# this gives us confidence that every NetCdf instance
# has the same attributes
self.log("initializing attributes")
self._initialize_attributes()
self.log("initializing attributes")
self.time_values_arg = time_values
self.log("initializing file")
self.initialize_file(time_values=self.time_values_arg)
self.log("initializing file")
def __add__(self, other):
new_net = NetCdf.zeros_like(self)
if np.isscalar(other) or isinstance(other, np.ndarray):
for vname in self.var_attr_dict.keys():
new_net.nc.variables[vname][:] = (
self.nc.variables[vname][:] + other
)
elif isinstance(other, NetCdf):
for vname in self.var_attr_dict.keys():
new_net.nc.variables[vname][:] = (
self.nc.variables[vname][:] + other.nc.variables[vname][:]
)
else:
raise Exception(
"NetCdf.__add__(): unrecognized other:{0}".format(
str(type(other))
)
)
return new_net
def __sub__(self, other):
new_net = NetCdf.zeros_like(self)
if np.isscalar(other) or isinstance(other, np.ndarray):
for vname in self.var_attr_dict.keys():
new_net.nc.variables[vname][:] = (
self.nc.variables[vname][:] - other
)
elif isinstance(other, NetCdf):
for vname in self.var_attr_dict.keys():
new_net.nc.variables[vname][:] = (
self.nc.variables[vname][:] - other.nc.variables[vname][:]
)
else:
raise Exception(
"NetCdf.__sub__(): unrecognized other:{0}".format(
str(type(other))
)
)
return new_net
def __mul__(self, other):
new_net = NetCdf.zeros_like(self)
if np.isscalar(other) or isinstance(other, np.ndarray):
for vname in self.var_attr_dict.keys():
new_net.nc.variables[vname][:] = (
self.nc.variables[vname][:] * other
)
elif isinstance(other, NetCdf):
for vname in self.var_attr_dict.keys():
new_net.nc.variables[vname][:] = (
self.nc.variables[vname][:] * other.nc.variables[vname][:]
)
else:
raise Exception(
"NetCdf.__mul__(): unrecognized other:{0}".format(
str(type(other))
)
)
return new_net
def __div__(self, other):
return self.__truediv__(other)
def __truediv__(self, other):
new_net = NetCdf.zeros_like(self)
with np.errstate(invalid="ignore"):
if np.isscalar(other) or isinstance(other, np.ndarray):
for vname in self.var_attr_dict.keys():
new_net.nc.variables[vname][:] = (
self.nc.variables[vname][:] / other
)
elif isinstance(other, NetCdf):
for vname in self.var_attr_dict.keys():
new_net.nc.variables[vname][:] = (
self.nc.variables[vname][:]
/ other.nc.variables[vname][:]
)
else:
raise Exception(
"NetCdf.__sub__(): unrecognized other:{0}".format(
str(type(other))
)
)
return new_net
def append(self, other, suffix="_1"):
assert isinstance(other, NetCdf) or isinstance(other, dict)
if isinstance(other, NetCdf):
for vname in other.var_attr_dict.keys():
attrs = other.var_attr_dict[vname].copy()
var = other.nc.variables[vname]
new_vname = vname
if vname in self.nc.variables.keys():
if vname not in STANDARD_VARS:
new_vname = vname + suffix
if "long_name" in attrs:
attrs["long_name"] += " " + suffix
else:
continue
assert (
new_vname not in self.nc.variables.keys()
), "var already exists:{0} in {1}".format(
new_vname, ",".join(self.nc.variables.keys())
)
attrs["max"] = var[:].max()
attrs["min"] = var[:].min()
new_var = self.create_variable(
new_vname, attrs, var.dtype, dimensions=var.dimensions
)
new_var[:] = var[:]
else:
for vname, array in other.items():
vname_norm = self.normalize_name(vname)
assert (
vname_norm in self.nc.variables.keys()
), "dict var not in " "self.vars:{0}-->".format(
vname
) + ",".join(
self.nc.variables.keys()
)
new_vname = vname_norm + suffix
assert new_vname not in self.nc.variables.keys()
attrs = self.var_attr_dict[vname_norm].copy()
attrs["max"] = np.nanmax(array)
attrs["min"] = np.nanmin(array)
attrs["name"] = new_vname
attrs["long_name"] = attrs["long_name"] + " " + suffix
var = self.nc.variables[vname_norm]
# assert var.shape == array.shape,\
# "{0} shape ({1}) doesn't make array shape ({2})".\
# format(new_vname,str(var.shape),str(array.shape))
new_var = self.create_variable(
new_vname, attrs, var.dtype, dimensions=var.dimensions
)
try:
new_var[:] = array
except:
new_var[:, 0] = array
return
def copy(self, output_filename):
new_net = NetCdf.zeros_like(self, output_filename=output_filename)
for vname in self.var_attr_dict.keys():
new_net.nc.variables[vname][:] = self.nc.variables[vname][:]
return new_net
@classmethod
def zeros_like(
cls, other, output_filename=None, verbose=None, logger=None
):
new_net = NetCdf.empty_like(
other,
output_filename=output_filename,
verbose=verbose,
logger=logger,
)
# add the vars to the instance
for vname in other.var_attr_dict.keys():
if new_net.nc.variables.get(vname) is not None:
new_net.logger.warn(
"variable {0} already defined, skipping".format(vname)
)
continue
new_net.log("adding variable {0}".format(vname))
var = other.nc.variables[vname]
data = var[:]
try:
mask = data.mask
data = np.array(data)
except:
mask = None
new_data = np.zeros_like(data)
new_data[mask] = FILLVALUE
new_var = new_net.create_variable(
vname,
other.var_attr_dict[vname],
var.dtype,
dimensions=var.dimensions,
)
new_var[:] = new_data
new_net.log("adding variable {0}".format(vname))
global_attrs = {}
for attr in other.nc.ncattrs():
if attr not in new_net.nc.ncattrs():
global_attrs[attr] = other.nc[attr]
new_net.add_global_attributes(global_attrs)
return new_net
@classmethod
def empty_like(
cls, other, output_filename=None, verbose=None, logger=None
):
if output_filename is None:
output_filename = (
str(time.mktime(datetime.now().timetuple())) + ".nc"
)
while os.path.exists(output_filename):
print("{}...already exists".format(output_filename))
output_filename = (
str(time.mktime(datetime.now().timetuple())) + ".nc"
)
print(
"creating temporary netcdf file..."
+ "{}".format(output_filename)
)
new_net = cls(
output_filename,
other.model,
time_values=other.time_values_arg,
verbose=verbose,
logger=logger,
)
return new_net
def difference(
self, other, minuend="self", mask_zero_diff=True, onlydiff=True
):
"""
make a new NetCDF instance that is the difference with another
netcdf file
Parameters
----------
other : either an str filename of a netcdf file or
a netCDF4 instance
minuend : (optional) the order of the difference operation.
Default is self (e.g. self - other). Can be "self" or "other"
mask_zero_diff : bool flag to mask differences that are zero. If
True, positions in the difference array that are zero will be set
to self.fillvalue
only_diff : bool flag to only add non-zero diffs to output file
Returns
-------
net NetCDF instance
Notes
-----
assumes the current NetCDF instance has been populated. The
variable names and dimensions between the two files must match
exactly. The name of the new .nc file is
<self.output_filename>.diff.nc. The masks from both self and
other are carried through to the new instance
"""
assert self.nc is not None, (
"can't call difference() if nc " + "hasn't been populated"
)
try:
import netCDF4
except Exception as e:
mess = "error import netCDF4: {0}".format(str(e))
self.logger.warn(mess)
raise Exception(mess)
if isinstance(other, str):
assert os.path.exists(
other
), "filename 'other' not found:" + "{0}".format(other)
other = netCDF4.Dataset(other, "r")
assert isinstance(other, netCDF4.Dataset)
# check for similar variables
self_vars = set(self.nc.variables.keys())
other_vars = set(other.variables)
diff = self_vars.symmetric_difference(other_vars)
if len(diff) > 0:
self.logger.warn(
"variables are not the same between the two "
+ "nc files: "
+ ",".join(diff)
)
return
# check for similar dimensions
self_dimens = self.nc.dimensions
other_dimens = other.dimensions
for d in self_dimens.keys():
if d not in other_dimens:
self.logger.warn("missing dimension in other:{0}".format(d))
return
if len(self_dimens[d]) != len(other_dimens[d]):
self.logger.warn(
"dimension not consistent: "
+ "{0}:{1}".format(self_dimens[d], other_dimens[d])
)
return
# should be good to go
time_values = self.nc.variables.get("time")[:]
new_net = NetCdf(
self.output_filename.replace(".nc", ".diff.nc"),
self.model,
time_values=time_values,
)
# add the vars to the instance
for vname in self_vars:
if (
vname not in self.var_attr_dict
or new_net.nc.variables.get(vname) is not None
):
self.logger.warn("skipping variable: {0}".format(vname))
continue
self.log("processing variable {0}".format(vname))
s_var = self.nc.variables[vname]
o_var = other.variables[vname]
s_data = s_var[:]
o_data = o_var[:]
o_mask, s_mask = None, None
# keep the masks to apply later
if isinstance(s_data, np.ma.MaskedArray):
self.logger.warn("masked array for {0}".format(vname))
s_mask = s_data.mask
s_data = np.array(s_data)
s_data[s_mask] = 0.0
else:
np.nan_to_num(s_data)
if isinstance(o_data, np.ma.MaskedArray):
o_mask = o_data.mask
o_data = np.array(o_data)
o_data[o_mask] = 0.0
else:
np.nan_to_num(o_data)
# difference with self
if minuend.lower() == "self":
d_data = s_data - o_data
elif minuend.lower() == "other":
d_data = o_data - s_data
else:
mess = "unrecognized minuend {0}".format(minuend)
self.logger.warn(mess)
raise Exception(mess)
# check for non-zero diffs
if onlydiff and d_data.sum() == 0.0:
self.logger.warn(
"var {0} has zero differences, skipping...".format(vname)
)
continue
self.logger.warn(
"resetting diff attrs max,min:{0},{1}".format(
d_data.min(), d_data.max()
)
)
attrs = self.var_attr_dict[vname].copy()
attrs["max"] = np.nanmax(d_data)
attrs["min"] = np.nanmin(d_data)
# reapply masks
if s_mask is not None:
self.log("applying self mask")
s_mask[d_data != 0.0] = False
d_data[s_mask] = FILLVALUE
self.log("applying self mask")
if o_mask is not None:
self.log("applying other mask")
o_mask[d_data != 0.0] = False
d_data[o_mask] = FILLVALUE
self.log("applying other mask")
d_data[np.isnan(d_data)] = FILLVALUE
if mask_zero_diff:
d_data[np.where(d_data == 0.0)] = FILLVALUE
var = new_net.create_variable(
vname, attrs, s_var.dtype, dimensions=s_var.dimensions
)
var[:] = d_data
self.log("processing variable {0}".format(vname))
def _dt_str(self, dt):
"""for datetime to string for year < 1900"""
dt_str = "{0:04d}-{1:02d}-{2:02d}T{3:02d}:{4:02d}:{5:02}Z".format(
dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second
)
return dt_str
def write(self):
"""write the nc object to disk"""
self.log("writing nc file")
assert (
self.nc is not None
), "netcdf.write() error: nc file not initialized"
# write any new attributes that have been set since
# initializing the file
for k, v in self.global_attributes.items():
try:
if self.nc.attributes.get(k) is not None:
self.nc.setncattr(k, v)
except Exception:
self.logger.warn(
"error setting global attribute {0}".format(k)
)
self.nc.sync()
self.nc.close()
self.log("writing nc file")
def _initialize_attributes(self):
"""private method to initial the attributes
of the NetCdf instance
"""
assert (
"nc" not in self.__dict__.keys()
), "NetCdf._initialize_attributes() error: nc attribute already set"
self.nc_epsg_str = "epsg:4326"
self.nc_crs_longname = "http://www.opengis.net/def/crs/EPSG/0/4326"
self.nc_semi_major = float(6378137.0)
self.nc_inverse_flat = float(298.257223563)
self.global_attributes = {}
self.global_attributes["namefile"] = self.model.namefile
self.global_attributes["model_ws"] = self.model.model_ws
self.global_attributes["exe_name"] = self.model.exe_name
self.global_attributes["modflow_version"] = self.model.version
self.global_attributes["create_hostname"] = socket.gethostname()
self.global_attributes["create_platform"] = platform.system()
self.global_attributes["create_directory"] = os.getcwd()
htol, rtol = -999, -999
try:
htol, rtol = self.model.solver_tols()
except Exception as e:
self.logger.warn(
"unable to get solver tolerances:" + "{0}".format(str(e))
)
self.global_attributes["solver_head_tolerance"] = htol
self.global_attributes["solver_flux_tolerance"] = rtol
spatial_attribs = {
"xll": self.model_grid.xoffset,
"yll": self.model_grid.yoffset,
"rotation": self.model_grid.angrot,
"proj4_str": self.model_grid.proj4,
}
for n, v in spatial_attribs.items():
self.global_attributes["flopy_sr_" + n] = v
self.global_attributes[
"start_datetime"
] = self.model_time.start_datetime
self.fillvalue = FILLVALUE
# initialize attributes
self.grid_crs = None
self.zs = None
self.ys = None
self.xs = None
self.nc = None
def initialize_geometry(self):
"""initialize the geometric information
needed for the netcdf file
"""
try:
import pyproj
except ImportError as e:
raise ImportError(
"NetCdf error importing pyproj module:\n" + str(e)
)
from distutils.version import LooseVersion
# Check if using newer pyproj version conventions
pyproj220 = LooseVersion(pyproj.__version__) >= LooseVersion("2.2.0")
proj4_str = self.proj4_str
print("initialize_geometry::proj4_str = {}".format(proj4_str))
self.log("building grid crs using proj4 string: {}".format(proj4_str))
if pyproj220:
self.grid_crs = pyproj.CRS(proj4_str)
else:
self.grid_crs = pyproj.Proj(proj4_str, preserve_units=True)
print("initialize_geometry::self.grid_crs = {}".format(self.grid_crs))
vmin, vmax = self.model_grid.botm.min(), self.model_grid.top.max()
if self.z_positive == "down":
vmin, vmax = vmax, vmin
else:
self.zs = self.model_grid.xyzcellcenters[2].copy()
ys = self.model_grid.xyzcellcenters[1].copy()
xs = self.model_grid.xyzcellcenters[0].copy()
# Transform to a known CRS
if pyproj220:
nc_crs = pyproj.CRS(self.nc_epsg_str)
self.transformer = pyproj.Transformer.from_crs(
self.grid_crs, nc_crs, always_xy=True
)
else:
nc_crs = pyproj.Proj(self.nc_epsg_str)
self.transformer = None
print("initialize_geometry::nc_crs = {}".format(nc_crs))
if pyproj220:
print(
"transforming coordinates using = {}".format(self.transformer)
)
self.log("projecting grid cell center arrays")
if pyproj220:
self.xs, self.ys = self.transformer.transform(xs, ys)
else:
self.xs, self.ys = pyproj.transform(self.grid_crs, nc_crs, xs, ys)
# get transformed bounds and record to check against ScienceBase later
xmin, xmax, ymin, ymax = self.model_grid.extent
bbox = np.array(
[[xmin, ymin], [xmin, ymax], [xmax, ymax], [xmax, ymin]]
)
if pyproj220:
x, y = self.transformer.transform(*bbox.transpose())
else:
x, y = pyproj.transform(self.grid_crs, nc_crs, *bbox.transpose())
self.bounds = x.min(), y.min(), x.max(), y.max()
self.vbounds = vmin, vmax
def initialize_file(self, time_values=None):
"""
initialize the netcdf instance, including global attributes,
dimensions, and grid information
Parameters
----------
time_values : list of times to use as time dimension
entries. If none, then use the times in
self.model.dis.perlen and self.start_datetime
"""
if self.nc is not None:
raise Exception("nc file already initialized")
if self.grid_crs is None:
self.log("initializing geometry")
self.initialize_geometry()
self.log("initializing geometry")
try:
import netCDF4
except Exception as e:
self.logger.warn("error importing netCDF module")
msg = "NetCdf error importing netCDF4 module:\n" + str(e)
raise Exception(msg)
# open the file for writing
try:
self.nc = netCDF4.Dataset(self.output_filename, "w")
except Exception as e:
msg = "error creating netcdf dataset:\n{}".format(str(e))
raise Exception(msg)
# write some attributes
self.log("setting standard attributes")
self.nc.setncattr(
"Conventions",
"CF-1.6, ACDD-1.3, flopy {}".format(flopy.__version__),
)
self.nc.setncattr(
"date_created", datetime.utcnow().strftime("%Y-%m-%dT%H:%M:00Z")
)
self.nc.setncattr(
"geospatial_vertical_positive", "{}".format(self.z_positive)
)
min_vertical = np.min(self.zs)
max_vertical = np.max(self.zs)
self.nc.setncattr("geospatial_vertical_min", min_vertical)
self.nc.setncattr("geospatial_vertical_max", max_vertical)
self.nc.setncattr("geospatial_vertical_resolution", "variable")
self.nc.setncattr("featureType", "Grid")
for k, v in self.global_attributes.items():
try:
self.nc.setncattr(k, v)
except:
self.logger.warn(
"error setting global attribute {0}".format(k)
)
self.global_attributes = {}
self.log("setting standard attributes")
# spatial dimensions
self.log("creating dimensions")
# time
if time_values is None:
time_values = np.cumsum(self.model_time.perlen)
self.nc.createDimension("time", len(time_values))
for name, length in zip(self.dimension_names, self.shape):
self.nc.createDimension(name, length)
self.log("creating dimensions")
self.log("setting CRS info")
# Metadata variables
crs = self.nc.createVariable("crs", "i4")
crs.long_name = self.nc_crs_longname
crs.epsg_code = self.nc_epsg_str
crs.semi_major_axis = self.nc_semi_major
crs.inverse_flattening = self.nc_inverse_flat
self.log("setting CRS info")
attribs = {
"units": "{} since {}".format(
self.time_units, self.start_datetime
),
"standard_name": "time",
"long_name": NC_LONG_NAMES.get("time", "time"),
"calendar": "gregorian",
"_CoordinateAxisType": "Time",
}
time = self.create_variable(
"time", attribs, precision_str="f8", dimensions=("time",)
)
self.logger.warn("time_values:{0}".format(str(time_values)))
time[:] = np.asarray(time_values)
# Elevation
attribs = {
"units": self.model_grid.units,
"standard_name": "elevation",
"long_name": NC_LONG_NAMES.get("elevation", "elevation"),
"axis": "Z",
"valid_min": min_vertical,
"valid_max": max_vertical,
"positive": self.z_positive,
}
elev = self.create_variable(
"elevation",
attribs,
precision_str="f8",
dimensions=self.dimension_names,
)
elev[:] = self.zs
# Longitude
attribs = {
"units": "degrees_east",
"standard_name": "longitude",
"long_name": NC_LONG_NAMES.get("longitude", "longitude"),
"axis": "X",
"_CoordinateAxisType": "Lon",
}
lon = self.create_variable(
"longitude",
attribs,
precision_str="f8",
dimensions=self.dimension_names[1:],
)
lon[:] = self.xs
self.log("creating longitude var")
# Latitude
self.log("creating latitude var")
attribs = {
"units": "degrees_north",
"standard_name": "latitude",
"long_name": NC_LONG_NAMES.get("latitude", "latitude"),
"axis": "Y",
"_CoordinateAxisType": "Lat",
}
lat = self.create_variable(
"latitude",
attribs,
precision_str="f8",
dimensions=self.dimension_names[1:],
)
lat[:] = self.ys
# x
self.log("creating x var")
attribs = {
"units": self.model_grid.units,
"standard_name": "projection_x_coordinate",
"long_name": NC_LONG_NAMES.get("x", "x coordinate of projection"),
"axis": "X",
}
x = self.create_variable(
"x_proj",
attribs,
precision_str="f8",
dimensions=self.dimension_names[1:],
)
x[:] = self.model_grid.xyzcellcenters[0]
# y
self.log("creating y var")
attribs = {
"units": self.model_grid.units,
"standard_name": "projection_y_coordinate",
"long_name": NC_LONG_NAMES.get("y", "y coordinate of projection"),
"axis": "Y",
}
y = self.create_variable(
"y_proj",
attribs,
precision_str="f8",
dimensions=self.dimension_names[1:],
)
y[:] = self.model_grid.xyzcellcenters[1]
# grid mapping variable
crs = flopy.utils.reference.crs(
prj=self.model_grid.prj, epsg=self.model_grid.epsg
)
attribs = crs.grid_mapping_attribs
if attribs is not None:
self.log("creating grid mapping variable")
self.create_variable(
attribs["grid_mapping_name"], attribs, precision_str="f8"
)
# layer
self.log("creating layer var")
attribs = {
"units": "",
"standard_name": "layer",
"long_name": NC_LONG_NAMES.get("layer", "layer"),
"positive": "down",
"axis": "Z",
}
lay = self.create_variable("layer", attribs, dimensions=("layer",))
lay[:] = np.arange(0, self.shape[0])
self.log("creating layer var")
if self.model_grid.grid_type == "structured":
# delc
attribs = {
"units": self.model_grid.units.strip("s"),
"long_name": NC_LONG_NAMES.get(
"delc", "Model grid cell spacing along a column"
),
}
delc = self.create_variable("delc", attribs, dimensions=("y",))
delc[:] = self.model_grid.delc[::-1]
if self.model_grid.angrot != 0:
delc.comments = (
"This is the row spacing that applied to the UNROTATED grid. "
+ "This grid HAS been rotated before being saved to NetCDF. "
+ "To compute the unrotated grid, use the origin point and this array."
)
# delr
attribs = {
"units": self.model_grid.units.strip("s"),
"long_name": NC_LONG_NAMES.get(
"delr", "Model grid cell spacing along a row"
),
}
delr = self.create_variable("delr", attribs, dimensions=("x",))
delr[:] = self.model_grid.delr[::-1]
if self.model_grid.angrot != 0:
delr.comments = (
"This is the col spacing that applied to the UNROTATED grid. "
+ "This grid HAS been rotated before being saved to NetCDF. "
+ "To compute the unrotated grid, use the origin point and this array."
)
# else:
# vertices
# attribs = {"units": self.model_grid.lenuni.strip('s'),
# "long_name": NC_LONG_NAMES.get("vertices",
# "List of vertices used in the model by cell"),
# }
# vertices = self.create_variable('vertices', attribs, dimensions=('ncpl',))
# vertices[:] = self.model_grid.vertices
# Workaround for CF/CDM.
# http://www.unidata.ucar.edu/software/thredds/current/netcdf-java/
# reference/StandardCoordinateTransforms.html
# "explicit_field"
exp = self.nc.createVariable("VerticalTransform", "S1")
exp.transform_name = "explicit_field"
exp.existingDataField = "elevation"
exp._CoordinateTransformType = "vertical"
exp._CoordinateAxes = "layer"
return
def initialize_group(
self,
group="timeseries",
dimensions=("time",),
attributes=None,
dimension_data=None,
):
"""
Method to initialize a new group within a netcdf file. This group
can have independent dimensions from the global dimensions
Parameters:
----------
name : str
name of the netcdf group
dimensions : tuple
data dimension names for group
dimension_shape : tuple
tuple of data dimension lengths
attributes : dict
nested dictionary of {dimension : {attributes}} for each netcdf
group dimension
dimension_data : dict
dictionary of {dimension : [data]} for each netcdf group dimension
"""
if attributes is None:
attributes = {}
if dimension_data is None:
dimension_data = {}
if self.nc is None:
self.initialize_file()
if group in self.nc.groups:
raise AttributeError("{} group already initialized".format(group))
self.log("creating netcdf group {}".format(group))
self.nc.createGroup(group)
self.log("{} group created".format(group))
self.log("creating {} group dimensions".format(group))
for dim in dimensions:
if dim == "time":
if "time" not in dimension_data:
time_values = np.cumsum(self.model_time.perlen)
else:
time_values = dimension_data["time"]
self.nc.groups[group].createDimension(dim, len(time_values))
else:
if dim not in dimension_data:
raise AssertionError(
"{} information must be supplied "
"to dimension data".format(dim)
)
else:
self.nc.groups[group].createDimension(
dim, len(dimension_data[dim])
)
self.log("created {} group dimensions".format(group))
dim_names = tuple([i for i in dimensions if i != "time"])
for dim in dimensions:
if dim.lower() == "time":
if "time" not in attributes:
unit_value = "{} since {}".format(
self.time_units, self.start_datetime
)
attribs = {
"units": unit_value,
"standard_name": "time",
"long_name": NC_LONG_NAMES.get("time", "time"),
"calendar": "gregorian",
"Axis": "Y",
"_CoordinateAxisType": "Time",
}
else:
attribs = attributes["time"]
time = self.create_group_variable(
group,
"time",
attribs,
precision_str="f8",
dimensions=("time",),
)
time[:] = np.asarray(time_values)
elif dim.lower() == "zone":
if "zone" not in attributes:
attribs = {
"units": "N/A",
"standard_name": "zone",
"long_name": "zonebudget zone",
"Axis": "X",
"_CoordinateAxisType": "Zone",
}
else:
attribs = attributes["zone"]
zone = self.create_group_variable(
group,
"zone",
attribs,
precision_str="i4",
dimensions=("zone",),
)
zone[:] = np.asarray(dimension_data["zone"])
else:
attribs = attributes[dim]
var = self.create_group_variable(
group,
dim,
attribs,
precision_str="f8",
dimensions=dim_names,
)
var[:] = np.asarray(dimension_data[dim])
@staticmethod
def normalize_name(name):
return name.replace(".", "_").replace(" ", "_").replace("-", "_")
def create_group_variable(
self, group, name, attributes, precision_str, dimensions=("time",)
):
"""
Create a new group variable in the netcdf object
Parameters
----------
name : str
the name of the variable
attributes : dict
attributes to add to the new variable
precision_str : str
netcdf-compliant string. e.g. f4
dimensions : tuple
which dimensions the variable applies to
default : ("time","layer","x","y")
group : str
which netcdf group the variable goes in
default : None which creates the variable in root
Returns
-------
nc variable
Raises
------
AssertionError if precision_str not right
AssertionError if variable name already in netcdf object
AssertionError if one of more dimensions do not exist
"""
name = self.normalize_name(name)
if (
name in STANDARD_VARS
and name in self.nc.groups[group].variables.keys()
):
return
if name in self.nc.groups[group].variables.keys():
if self.forgive:
self.logger.warn(
"skipping duplicate {} group variable: {}".format(
group, name
)
)
return
else:
raise Exception(
"duplicate {} group variable name: {}".format(group, name)
)
self.log("creating group {} variable: {}".format(group, name))
if precision_str not in PRECISION_STRS:
raise AssertionError(
"netcdf.create_variable() error: precision "
"string {} not in {}".format(precision_str, PRECISION_STRS)
)
if group not in self.nc.groups:
raise AssertionError(
"netcdf group `{}` must be created before "
"variables can be added to it".format(group)
)
self.var_attr_dict["{}/{}".format(group, name)] = attributes
var = self.nc.groups[group].createVariable(
name,
precision_str,
dimensions,
fill_value=self.fillvalue,
zlib=True,
)
for k, v in attributes.items():
try:
var.setncattr(k, v)
except:
self.logger.warn(
"error setting attribute"
+ "{} for group {} variable {}".format(k, group, name)
)
self.log("creating group {} variable: {}".format(group, name))
return var
def create_variable(
self,
name,
attributes,
precision_str="f4",
dimensions=("time", "layer"),
group=None,
):
"""
Create a new variable in the netcdf object
Parameters
----------
name : str
the name of the variable
attributes : dict
attributes to add to the new variable
precision_str : str
netcdf-compliant string. e.g. f4
dimensions : tuple
which dimensions the variable applies to
default : ("time","layer","x","y")
group : str
which netcdf group the variable goes in
default : None which creates the variable in root
Returns
-------
nc variable
Raises
------
AssertionError if precision_str not right
AssertionError if variable name already in netcdf object
AssertionError if one of more dimensions do not exist
"""
# Normalize variable name
name = self.normalize_name(name)
# if this is a core var like a dimension...
# long_name = attributes.pop("long_name",name)
if name in STANDARD_VARS and name in self.nc.variables.keys():
return
if (
name not in self.var_attr_dict.keys()
and name in self.nc.variables.keys()
):
if self.forgive:
self.logger.warn(
"skipping duplicate variable: {0}".format(name)
)
return
else:
raise Exception("duplicate variable name: {0}".format(name))
if name in self.nc.variables.keys():
raise Exception("duplicate variable name: {0}".format(name))
self.log("creating variable: " + str(name))
assert (
precision_str in PRECISION_STRS
), "netcdf.create_variable() error: precision string {0} not in {1}".format(
precision_str, PRECISION_STRS
)
if self.nc is None:
self.initialize_file()
# check that the requested dimension exists and
# build up the chuck sizes
# chunks = []
# for dimension in dimensions:
# assert self.nc.dimensions.get(dimension) is not None, \
# "netcdf.create_variable() dimension not found:" + dimension
# chunk = self.chunks[dimension]
# assert chunk is not None, \
# "netcdf.create_variable() chunk size of {0} is None in self.chunks". \
# format(dimension)
# chunks.append(chunk)
self.var_attr_dict[name] = attributes
var = self.nc.createVariable(
name,
precision_str,
dimensions,
fill_value=self.fillvalue,
zlib=True,
) # ,
# chunksizes=tuple(chunks))
for k, v in attributes.items():
try:
var.setncattr(k, v)
except:
self.logger.warn(
"error setting attribute"
+ "{0} for variable {1}".format(k, name)
)
self.log("creating variable: " + str(name))
return var
def add_global_attributes(self, attr_dict):
"""add global attribute to an initialized file
Parameters
----------
attr_dict : dict(attribute name, attribute value)
Returns
-------
None
Raises
------
Exception of self.nc is None (initialize_file()
has not been called)
"""
if self.nc is None:
# self.initialize_file()
mess = (
"NetCDF.add_global_attributes() should only "
+ "be called after the file has been initialized"
)
self.logger.warn(mess)
raise Exception(mess)
self.log("setting global attributes")
self.nc.setncatts(attr_dict)
self.log("setting global attributes")
def add_sciencebase_metadata(self, id, check=True):
"""Add metadata from ScienceBase using the
flopy.export.metadata.acdd class.
Returns
-------
metadata : flopy.export.metadata.acdd object
"""
md = acdd(id, model=self.model)
if md.sb is not None:
if check:
self._check_vs_sciencebase(md)
# get set of public attributes
attr = {n for n in dir(md) if "_" not in n[0]}
# skip some convenience attributes
skip = {
"bounds",
"creator",
"sb",
"xmlroot",
"time_coverage",
"get_sciencebase_xml_metadata",
"get_sciencebase_metadata",
}
towrite = sorted(list(attr.difference(skip)))
for k in towrite:
v = md.__getattribute__(k)
if v is not None:
# convert everything to strings
if not isinstance(v, str):
if isinstance(v, list):
v = ",".join(v)
else:
v = str(v)
self.global_attributes[k] = v
self.nc.setncattr(k, v)
self.write()
return md
def _check_vs_sciencebase(self, md):
"""Check that model bounds read from flopy are consistent with those in ScienceBase."""
xmin, ymin, xmax, ymax = self.bounds
tol = 1e-5
assert md.geospatial_lon_min - xmin < tol
assert md.geospatial_lon_max - xmax < tol
assert md.geospatial_lat_min - ymin < tol
assert md.geospatial_lat_max - ymax < tol
assert md.geospatial_vertical_min - self.vbounds[0] < tol
assert md.geospatial_vertical_max - self.vbounds[1] < tol
def get_longnames_from_docstrings(self, outfile="longnames.json"):
"""
This is experimental.
Scrape Flopy module docstrings and return docstrings for parameters
included in the list of variables added to NetCdf object. Create
a dictionary of longnames keyed by the NetCdf variable names; make each
longname from the first sentence of the docstring for that parameter.
One major limitation is that variables from mflists often aren't described
in the docstrings.
"""
def startstop(ds):
"""Get just the Parameters section of the docstring."""
start, stop = 0, -1
for i, l in enumerate(ds):
if "Parameters" in l and "----" in ds[i + 1]:
start = i + 2
if l.strip() in ["Attributes", "Methods", "Returns", "Notes"]:
stop = i - 1
break
if i >= start and "----" in l:
stop = i - 2
break
return start, stop
def get_entries(ds):
"""Parse docstring entries into dictionary."""
stuff = {}
k = None
for line in ds:
if (
len(line) >= 5
and line[:4] == " " * 4
and line[4] != " "
and ":" in line
):
k = line.split(":")[0].strip()
stuff[k] = ""
# lines with parameter descriptions
elif k is not None and len(line) > 10: # avoid orphans
stuff[k] += line.strip() + " "
return stuff
# get a list of the flopy classes
# packages = inspect.getmembers(flopy.modflow, inspect.isclass)
packages = [(pp.name[0], pp) for pp in self.model.packagelist]
# get a list of the NetCDF variables
attr = [v.split("_")[-1] for v in self.nc.variables]
# parse docstrings to get long names
longnames = {}
for pkg in packages:
# parse the docstring
obj = pkg[-1]
ds = obj.__doc__.split("\n")
start, stop = startstop(ds)
txt = ds[start:stop]
if stop - start > 0:
params = get_entries(txt)
for k, v in params.items():
if k in attr:
longnames[k] = v.split(". ")[0]
# add in any variables that weren't found
for var in attr:
if var not in longnames.keys():
longnames[var] = ""
with open(outfile, "w") as output:
json.dump(longnames, output, sort_keys=True, indent=2)
return longnames