# # # This source code is subject to the license referenced at
# # # https://github.com/NRLMMD-GEOIPS.
"""Generalized geolocation calculations for geostationary satellites."""
import os
from datetime import datetime
import logging
import numpy as np
from pathlib import Path
from pyresample import utils
from pyresample.geometry import SwathDefinition
from pyresample.kd_tree import get_neighbour_info # , get_sample_from_neighbour_info
import time
import zarr
from geoips.errors import CoverageError
from geoips.filenames.base_paths import PATHS as gpaths
from geoips.utils.context_managers import import_optional_dependencies
LOG = logging.getLogger(__name__)
# interface = None indicates to the GeoIPS interfaces that this is not a valid
# plugin, and this module will not be added to the GeoIPS plugin registry.
# This allows including python modules within the geoips/plugins directory
# that provide helper or utility functions to the geoips plugins, but are
# not full GeoIPS plugins on their own.
interface = None
with import_optional_dependencies(loglevel="info"):
"""Attempt to import a package and print to LOG.info if the import fails."""
import numexpr as ne
nprocs = 6
try:
ne.set_num_threads(nprocs)
except Exception:
LOG.info(
"Failed numexpr.set_num_threads in %s. If numexpr is not installed and you "
"need it, install it.",
__file__,
)
DONT_AUTOGEN_GEOLOCATION = False
if os.getenv("DONT_AUTOGEN_GEOLOCATION"):
DONT_AUTOGEN_GEOLOCATION = True
STATIC_GEOLOCDIR = gpaths["GEOIPS_DATA_CACHE_DIR_LONGTERM_GEOLOCATION_STATIC"]
# default dynamic geoloc dir for NRL
DYNAMIC_GEOLOCDIR = gpaths["GEOIPS_DATA_CACHE_DIR_SHORTTERM_GEOLOCATION_DYNAMIC"]
READ_GEOLOCDIRS = []
if os.getenv("READ_GEOLOCDIRS"):
READ_GEOLOCDIRS = os.getenv("READ_GEOLOCDIRS").split(":")
[docs]class AutoGenError(Exception):
"""Raise exception on auto generated geolocation error."""
pass
[docs]class CachedGeolocationIndexError(IndexError):
"""Raise exception on cached geolocation IndexError."""
pass
[docs]class CacheNotFoundError(FileNotFoundError):
"""Raise exception if cached data is not found."""
pass
[docs]def check_geolocation_cache_backend(
cache_backend, supported_backends=("memmap", "zarr")
):
"""Check if requested geolocation cache backend is supported.
Perhaps this should be converted to a decorator later on?
Parameters
----------
cache_backend : str
Library name used to create cached geolocation files (e.g. zarr or memmap)
supported_backends : list or tuple, optional
Supported cache backends, by default ("memmap", "zarr")
Raises
------
ValueError
If cache_backend is not in supported_backends
"""
if cache_backend not in supported_backends:
raise ValueError(
f"Unsupported cache backend: {cache_backend}."
f" Supported cache backends: {supported_backends}"
)
[docs]def construct_cache_filename(
pref, metadata, area_def=None, cache_backend="memmap", chunk_size=None
):
"""Construct a cached file name.
Parameters
----------
pref : str
Prefix to identify type of cached data
metadata : dict
Top level metadata for dataset
area_def : pyresample.area_definition, optional
Area definition subsector of data, by default None
cache_backend : str, optional
Specify to use either numpy.memmao or zarray for cache, by default "memmap"
chunk_size : int, optional
zarray cache chunk size, by default None
Returns
-------
str
File name for cached data
"""
check_geolocation_cache_backend(cache_backend)
# In order to ensure consistency here, take a sha1 hash of the string representation
# of the dictionary values. hash is applied to the object itself, which appears to
# not be consistent from one Python 3 run to the next.
# The dictionary values themselves (sorted) SHOULD be consistent between runs.
from hashlib import sha1
metadata_string = ""
for mkey in sorted(metadata.keys()):
if mkey == "start_datetime":
continue
metadata_string += str(metadata[mkey])
md_hash = sha1(metadata_string.encode("ascii"), usedforsecurity=False).hexdigest()
# md_hash = hash(frozenset((k, v) for k, v in metadata.items()
# if isinstance(v, Hashable)))
fname = "{}_{}_{}x{}".format(
pref,
metadata["scene"],
metadata["num_lines"],
metadata["num_samples"],
)
if cache_backend != "memmap":
# Only replace whitespaces with a hyphen when not memmap to prevent regenerating
# any cached geolocation files
fname = fname.replace(" ", "-")
if chunk_size and cache_backend == "zarr":
# If chunking enabled, include the size in the file name
fname += f"_chunk{chunk_size}"
# If the filename format needs to change for the pre-generated geolocation
# files, please discuss prior to changing. It will force recreation of all
# files, which can be problematic for large numbers of sectors
if area_def:
ad = area_def
LOG.info("Using area_definition information ")
LOG.info(
" Using area_definition information for hash: "
+ str(ad.proj_dict.items())
)
# sector_hash = hash(frozenset(ad.proj_dict.items()))
from hashlib import sha1
metadata_string = ""
for mkey in sorted(ad.proj_dict.keys()):
# We don't want to include start/end datetimes in metadata hash!!
if mkey == "start_datetime":
continue
metadata_string += str(ad.proj_dict[mkey])
sector_hash = sha1(
metadata_string.encode("ascii"), usedforsecurity=False
).hexdigest()
sect_nlines = ad.shape[0]
sect_nsamples = ad.shape[1]
sect_clat = area_def.proj_dict["lat_0"]
sect_clon = area_def.proj_dict["lon_0"]
fname += "_{}_{}x{}_{}x{}".format(
area_def.area_id, sect_nlines, sect_nsamples, sect_clat, sect_clon
)
fname += "_{}_{}".format(md_hash, sector_hash)
else:
fname += "_{}".format(md_hash)
if cache_backend == "memmap":
fname += ".dat"
elif cache_backend == "zarr":
fname += ".zarr"
return fname
[docs]def get_data_cache_filename(
pref,
metadata,
area_def=None,
cache_backend="memmap",
chunk_size=None,
scan_datetime=None,
):
"""Get the full file path for a cached calibrated data file.
Parameters
----------
pref : str
Prefix to identify type of cached data
metadata : dict
Top level metadata for dataset
area_def : pyresample.area_definition, optional
Area definition subsector of data, by default None
cache_backend : str, optional
Specify to use either numpy.memmao or zarray for cache, by default "memmap"
chunk_size : int, optional
zarray cache chunk size, by default None
Returns
-------
str
Full file path for cached data
"""
cache_dir = os.path.join(
gpaths["GEOIPS_DATA_CACHE_DIR_SHORTTERM_CALIBRATED_DATA"],
)
if scan_datetime is not None:
pref += f"_{scan_datetime.strftime('%Y%m%dT%H%M%S.%fZ')}"
if not os.path.isdir(cache_dir):
try:
os.makedirs(cache_dir)
except FileExistsError:
pass
fname = construct_cache_filename(
pref,
metadata,
area_def=area_def,
cache_backend=cache_backend,
chunk_size=chunk_size,
)
return os.path.join(cache_dir, metadata["platform_name"], fname)
[docs]def get_geolocation_cache_filename(
pref,
metadata,
area_def=None,
geolocation_cache_backend="memmap",
chunk_size=None,
solar_angles=False,
):
"""Set the location and filename format for the cached geolocation files.
There is a separate filename format for satellite latlons and sector latlons
Notes
-----
Changing geolocation filename format will force recreation of all
files, which can be problematic for large numbers of sectors.
"""
if solar_angles:
cache = os.path.join(
gpaths["GEOIPS_DATA_CACHE_DIR_SHORTTERM_GEOLOCATION_SOLAR_ANGLES"],
metadata["platform_name"],
)
else:
cache = os.path.join(STATIC_GEOLOCDIR, metadata["platform_name"])
from geoips.sector_utils.utils import is_dynamic_sector
if is_dynamic_sector(area_def):
cache = os.path.join(DYNAMIC_GEOLOCDIR, metadata["platform_name"])
if not os.path.isdir(cache):
try:
os.makedirs(cache)
except FileExistsError:
pass
fname = construct_cache_filename(
pref,
metadata,
area_def=area_def,
cache_backend=geolocation_cache_backend,
chunk_size=chunk_size,
)
# Check alternative read-only directories (i.e. operational)
for dirname in READ_GEOLOCDIRS:
if os.path.exists(os.path.join(dirname, metadata["platform_name"], fname)):
return os.path.join(dirname, metadata["platform_name"], fname)
# If not found, return the normal cached filename
return os.path.join(cache, fname)
[docs]def check_for_partial_cache(
cache_filename, partial_cache_filename, cache_timeout_seconds=30
):
"""Check if a partial data cache exists.
These partial (temporary) caches are created when the cache is being created, and is
renamed once complete. If a partial cache is found, try for 30 seconds (default) to
see if it is renamed to the final cache_filename
Parameters
----------
cache_filename : str
Full path to complete cache
partial_cache_filename : str
Full path to temporary/partial cache
cache_timeout_seconds : int, optional
Retry window to check for final cache if a partial cache is found, by default 30
Raises
------
CacheNotFoundError
If cache_filename is not found after retry window
"""
no_coverage_cache = partial_cache_filename.replace("partial", "no_coverage")
if Path(partial_cache_filename).exists():
LOG.info("Found partial cache: %s", partial_cache_filename)
LOG.info("Will attempt to re-check for complete cache")
start_time = datetime.now()
partial_exists = Path(partial_cache_filename).exists()
while partial_exists:
elapsed_seconds = (datetime.now() - start_time).total_seconds()
if elapsed_seconds < cache_timeout_seconds:
LOG.debug("Partial cache still exists, will check again in 1 second")
time.sleep(1)
else:
LOG.error(
"Exceeded cache timeout of %s seconds."
" If problem persists, delete %s",
cache_timeout_seconds,
partial_cache_filename,
)
partial_exists = False
if Path(no_coverage_cache).exists():
# This will potentially cause an issue specifically for dynamic sectors with
# interpolated positions where the initial position (ie, storm location at 0Z)
# is outside satellite coverage, but a later interpolated position (e.g.,
# interpolated location at 5Z) may have coverage. We are going to accept this
# case, because the interpolated location will likely still be at edge of scan.
# Per images below, for TCs in particular, I really don't think we care about
# this, since they don't move much in 6h and I can't imagine we wouldn't want
# something at 0Z, but then would want it at 5Z.
# Note there may be future cases where we would not want to accept this risk, if
# a dynamic sector moves very quickly, and intermediate interpolated locations
# could vary drastically from the reported positions at specific times.
LOG.info("Cache flagged as having no coverage: %s", no_coverage_cache)
if not Path(cache_filename).exists():
error_str = f"Cache does not exist: {cache_filename}"
if Path(partial_cache_filename).exists():
error_str = (
f"{error_str}\n"
"Partial cache file DOES exist, try deleting it and re-running\n"
f" rm -fv {partial_cache_filename}"
)
raise CacheNotFoundError(error_str)
[docs]def create_empty_partial_cache(partial_cache_filename, cache_backend):
"""Create an empty cache with temporary name to denote cache is being processed.
Supported backends include zarray and memmap.
Parameters
----------
partial_cache_filename : str
Full path to temporary/partial cache
cache_backend : str
Backend format of cache
"""
LOG.debug("Creating %s", partial_cache_filename)
if cache_backend == "zarr":
# Zarrray data store is a directory
Path(partial_cache_filename).mkdir(parents=True, exist_ok=True)
else:
Path(partial_cache_filename).touch(exist_ok=True)
[docs]def rename_partial_cache(partial_cache_filename, final_cache_filename):
"""Rename partial cache to final cache file name.
Parameters
----------
partial_cache_filename : str
Full path to temporary/partial cache
final_cache_filename : str
Full path to complete cache
"""
LOG.info("Renaming %s as %s", partial_cache_filename, final_cache_filename)
Path(partial_cache_filename).rename(Path(final_cache_filename))
[docs]def get_geolocation(
dt,
gmd,
fldk_lats,
fldk_lons,
BADVALS,
area_def=None,
resolution=None,
geolocation_cache_backend="memmap",
chunk_size=None,
cache_solar_angles=False,
scan_datetime=None,
resource_tracker=None,
cache_timeout_seconds=30,
):
"""
Gather and return the geolocation data for the input metadata.
Input metadata should be the metadata for a single ABI data file.
If latitude/longitude have not been calculated with the metadata form the
input data file they will be recalculated and stored for future use.
They shouldn't change often. This will be slow the first time it is called
after a metadata update, but fast thereafter.
The same is true for satellite zenith and azimuth angles.
Solar zenith ang azimuth angles are always calculated on the fly.
This is because they actually change.
This may be slow for full-disk images.
"""
check_geolocation_cache_backend(geolocation_cache_backend)
adname = "None"
if area_def:
adname = area_def.area_id
if resolution:
adname += f"_{resolution}"
if resource_tracker is not None:
key = f"GETGEO: {adname}".replace("None", "ALL")
resource_tracker.track_resource_usage(
logstr="MEMUSG", verbose=False, key=key, increment_key=True
)
try:
fldk_sat_zen, fldk_sat_azm = get_satellite_angles(
gmd,
fldk_lats,
fldk_lons,
BADVALS,
area_def,
geolocation_cache_backend=geolocation_cache_backend,
chunk_size=chunk_size,
resource_tracker=resource_tracker,
cache_timeout_seconds=cache_timeout_seconds,
)
except AutoGenError:
return False
# Determine which indicies will be needed for the input sector if there is one.
if area_def is not None:
try:
lines, samples = get_indexes(
gmd,
fldk_lats,
fldk_lons,
area_def,
geolocation_cache_backend=geolocation_cache_backend,
chunk_size=chunk_size,
resource_tracker=resource_tracker,
cache_timeout_seconds=cache_timeout_seconds,
)
except AutoGenError:
return False
# Get lats, lons, and satellite zenith and azimuth angles for the required
# points. This may not be entirely appropriate, especially if we want to do
# something better than nearest neighbor interpolation.
shape = area_def.shape
# I'm not entirely sure why this happens from time to time, but sometimes the
# lines/samples are the correct size, but not the correct shape. I should really
# figure out the reason for this issue, but it's not entirely clear so in the
# mean time reshape here as needed.
if len(lines.shape) == 1 and lines.size == shape[0] * shape[1]:
LOG.warning("Reshaping lines and samples - should not have to do this...")
lines = np.reshape(lines, shape)
samples = np.reshape(samples, shape)
index_mask = lines != -999
lons = np.full(shape, -999.1)
lats = np.full(shape, -999.1)
sat_zen = np.full(shape, -999.1)
sat_azm = np.full(shape, -999.1)
try:
LOG.info("GETGEO Pulling lons from inds for %s", adname)
lons[index_mask] = fldk_lons[lines[index_mask], samples[index_mask]]
LOG.info("GETGEO Pulling lats from inds for %s", adname)
lats[index_mask] = fldk_lats[lines[index_mask], samples[index_mask]]
LOG.info("GETGEO Pulling sat_zen from inds for %s", adname)
sat_zen[index_mask] = fldk_sat_zen[lines[index_mask], samples[index_mask]]
LOG.info("GETGEO Pulling sat_azm from inds for %s", adname)
sat_azm[index_mask] = fldk_sat_azm[lines[index_mask], samples[index_mask]]
except IndexError as resp:
raise CachedGeolocationIndexError(resp)
else:
lats = fldk_lats
lons = fldk_lons
sat_zen = fldk_sat_zen
sat_azm = fldk_sat_azm
# Get generator for solar zenith and azimuth angles
LOG.info("GETGEO Must calculate solar zen/azm for sector %s", adname)
sun_zen, sun_azm = calculate_solar_angles(
gmd,
lats,
lons,
dt,
resource_tracker=resource_tracker,
area_def=area_def,
geolocation_cache_backend=geolocation_cache_backend,
chunk_size=chunk_size,
cache_solar_angles=cache_solar_angles,
scan_datetime=scan_datetime,
cache_timeout_seconds=cache_timeout_seconds,
)
LOG.info("GETGEO Done calculating solar zen/azm for sector %s", adname)
# Satellite zenith angle masks are set appropriately for off-disk
sat_zen = np.ma.masked_less_equal(sat_zen, -999.1)
sat_azm = np.ma.masked_less_equal(sat_azm, -999.1)
# Ensure sun_zen and sun_azm are masked appropriately for off-disk values.
sun_zen = np.ma.masked_where(sat_zen.mask == True, sun_zen)
sun_azm = np.ma.masked_where(sat_zen.mask == True, sun_azm)
# There is a bug in the satellite azimuth calculation where it is off by 180°
# It doesn't seem like it is an issue with the output being in 0-360 vs -180 - 180
# Converting to -180 - 180 will not yield the correct result.
if sat_azm.max() > 180:
# sat_azm[sat_azm > 180] -= 360 <- This does not work
sat_azm -= 180
# There is another bug in the solar azimuth calculation that causes the output to
# be in radians, and in degrees east:
if sun_azm.min() > 0 and sun_azm.max() < 2 * np.pi:
# If our full disk solar azimuth angle array has minimum greater than zero,
# then we are dealing with the bugged calculation. Need to apply the following
# corrections:
# 1: Convert from radians to degrees
# 2: Convert from 0-360 to -180 - 180
sun_azm = np.rad2deg(sun_azm)
sun_azm[sun_azm > 180] -= 360
if resource_tracker is not None:
resource_tracker.track_resource_usage(
key=key, checkpoint=True, increment_key=True
)
if area_def is not None:
# area_def.get_lonlats does NOT include any off disk masking.
lons, lats = area_def.get_lonlats()
# Set the lats and lons mask to sat_zen, which was set appropriately above.
lats = np.ma.masked_where(sat_zen.mask == True, lats)
lons = np.ma.masked_where(sat_zen.mask == True, lons)
# Make into a dict
# All the off-disk masking was set above, just catch any straggler -999 values.
geolocation = {
"latitude": np.ma.masked_less_equal(lats, -999.1),
"longitude": np.ma.masked_less_equal(lons, -999.1),
"satellite_zenith_angle": np.ma.masked_less_equal(sat_zen, -999.1),
"satellite_azimuth_angle": np.ma.masked_less_equal(sat_azm, -999.1),
"solar_zenith_angle": np.ma.masked_less_equal(sun_zen, -999.1),
"solar_azimuth_angle": np.ma.masked_less_equal(sun_azm, -999.1),
}
if resource_tracker is not None:
resource_tracker.track_resource_usage(
key=key, checkpoint=True, increment_key=True
)
try:
geolocation["Lines"] = np.array(lines)
geolocation["Samples"] = np.array(samples)
except NameError:
pass
if resource_tracker is not None:
resource_tracker.track_resource_usage(
logstr="MEMUSG", verbose=False, key=key, increment_key=True
)
return geolocation
[docs]def get_satellite_angles(
metadata,
lats,
lons,
BADVALS,
sect=None,
geolocation_cache_backend="memmap",
chunk_size=None,
resource_tracker=None,
cache_timeout_seconds=30,
):
"""Get satellite angles."""
# If the filename format needs to change for the pre-generated geolocation
# files, please discuss prior to changing. It will force recreation of all
# files, which can be problematic for large numbers of sectors
check_geolocation_cache_backend(geolocation_cache_backend)
fname = get_geolocation_cache_filename(
"GEOSAT",
metadata,
geolocation_cache_backend=geolocation_cache_backend,
chunk_size=chunk_size,
)
fname_partial = fname + ".partial"
if resource_tracker is not None:
key = "GEO SAT ANGLES: " + str(Path(fname).name)
if sect:
key += f"_{sect.area_id}"
resource_tracker.track_resource_usage(
logstr="MEMUSG", verbose=False, key=key, increment_key=True
)
if not Path(fname).exists() and not Path(fname_partial).exists():
# First touch a file with the partial cache name to identify we're creating the
# cache.
create_empty_partial_cache(fname_partial, geolocation_cache_backend)
if sect is not None and DONT_AUTOGEN_GEOLOCATION and "tc2019" not in sect.name:
msg = (
"GETGEO Requested NO AUTOGEN GEOLOCATION. "
+ "Could not create sat_file for ad {}: {}"
).format(metadata["scene"], fname)
LOG.error(msg)
raise AutoGenError(msg)
LOG.info("Calculating satellite zenith and azimuth angles.")
pi = np.pi
deg2rad = pi / 180.0 # NOQA
rad2deg = 180.0 / pi # NOQA
sub_lat = 0.0 # NOQA
sub_lon = metadata["lon0"] # NOQA
alt = metadata["H_m"] / 1000.0 # NOQA
num_lines = metadata["num_lines"] # NOQA
num_samples = metadata["num_samples"] # NOQA
# Convert lats / lons to radians from sub point
LOG.debug("Calculating beta")
# fmt: off
beta = ne.evaluate("arccos(cos(deg2rad * (lats - sub_lat)) * cos(deg2rad * (lons - sub_lon)))") # NOQA
# fmt: on
bad = lats == BADVALS["Off_Of_Disk"]
if resource_tracker is not None:
resource_tracker.track_resource_usage(
key=key, checkpoint=True, increment_key=True
)
# Calculate satellite zenith angle
LOG.debug("Calculating satellite zenith angle")
zen = ne.evaluate("alt * sin(beta) / sqrt(1.808e9 - 5.3725e8 * cos(beta))")
# Where statements take the place of np.clip(zen, - 1.0, 1.0)
ne.evaluate(
"rad2deg * arcsin(where(zen < -1.0, -1.0, where(zen > 1.0, 1.0, zen)))",
out=zen,
)
zen[bad] = BADVALS["Off_Of_Disk"]
if resource_tracker is not None:
resource_tracker.track_resource_usage(
key=key, checkpoint=True, increment_key=True
)
# Sat azimuth
LOG.debug("Calculating satellite azimuth angle")
azm = ne.evaluate("sin(deg2rad * (lons - sub_lon)) / sin(beta)")
ne.evaluate(
"rad2deg * arcsin(where(azm < -1.0, -1.0, where(azm > 1.0, 1.0, azm)))",
out=azm,
)
ne.evaluate("where(lats < sub_lat, 180.0 - azm, azm)", out=azm)
ne.evaluate("where(azm < 0.0, 360.0 + azm, azm)", out=azm)
azm[bad] = BADVALS["Off_Of_Disk"]
if resource_tracker is not None:
resource_tracker.track_resource_usage(
key=key, checkpoint=True, increment_key=True
)
LOG.info("Done calculating satellite zenith and azimuth angles")
if geolocation_cache_backend == "memmap":
LOG.info("Storing to %s", fname)
with open(fname_partial, "w") as df:
zen.tofile(df)
azm.tofile(df)
elif geolocation_cache_backend == "zarr":
if chunk_size:
chunks = (chunk_size, chunk_size)
else:
chunks = None
LOG.info("Storing sat zen/azm to %s (chunks=%s)", fname, chunks)
# NOTE zarr does NOT have a close method, so you can NOT use with context.
zf = zarr.open(fname_partial, mode="w")
# Assume azm and zen shape and dtype are the same
kwargs = {
"shape": azm.shape,
"dtype": azm.dtype,
}
# As of Python 3.11, can't pass chunks=None into create_dataset
if chunks:
# Chunks must be a tuple of the same shape as array.
kwargs["chunks"] = tuple([chunk_size] * azm.ndim)
zf.create_dataset("azm", **kwargs)
zf.create_dataset("zen", **kwargs)
zf["azm"][:] = azm
zf["zen"][:] = zen
rename_partial_cache(fname_partial, fname)
# Possible switch to xarray based geolocation files, but we lose memmapping.
# ds = xarray.Dataset({'zeniths':(['x','y'],zen),'azimuths':(['x','y'],azm)})
# ds.to_netcdf(fname)
else:
check_for_partial_cache(fname, fname_partial, cache_timeout_seconds)
if geolocation_cache_backend == "memmap":
# Create a memmap to the lat/lon file
# Nothing will be read until explicitly requested
# We are mapping this here so that the lats and lons are available when
# calculating satlelite angles
LOG.info(
"GETGEO memmap to {} : lat/lon file for {}".format(
fname, metadata["scene"]
)
)
shape = (metadata["num_lines"], metadata["num_samples"])
offset = 8 * metadata["num_samples"] * metadata["num_lines"]
zen = np.memmap(fname, mode="r", dtype=np.float64, offset=0, shape=shape)
if resource_tracker is not None:
resource_tracker.track_resource_usage(
key=key, checkpoint=True, increment_key=True
)
azm = np.memmap(
fname, mode="r", dtype=np.float64, offset=offset, shape=shape
)
if resource_tracker is not None:
resource_tracker.track_resource_usage(
key=key, checkpoint=True, increment_key=True
)
# Possible switch to xarray based geolocation files, but we lose memmapping.
# saved_xarray = xarray.load_dataset(fname)
# zen = saved_xarray['zeniths'].to_masked_array()
# azm = saved_xarray['azimuths'].to_masked_array()
elif geolocation_cache_backend == "zarr":
LOG.info(
"GETGEO zarr to {} : azm/zen file for {}".format(
fname, metadata["scene"]
)
)
shape = (metadata["num_lines"], metadata["num_samples"])
# chunk_x, chunk_y = [int(x/100) for x in lats.shape]
# NOTE zarr does NOT have a close method, so you can NOT use with context.
zf = zarr.open(fname, mode="r")
azm = zf["azm"]
zen = zf["zen"]
if resource_tracker is not None:
resource_tracker.track_resource_usage(
logstr="MEMUSG", verbose=False, key=key, increment_key=True
)
return zen, azm
[docs]def get_indexes(
metadata,
lats,
lons,
area_def,
geolocation_cache_backend="memmap",
chunk_size=None,
resource_tracker=None,
cache_timeout_seconds=30,
):
"""
Return two 2-D arrays containing the X and Y indexes.
These are indices that should be used from the raw data for the input
sector definition.
"""
# The get_neighbor_info function returns three four arrays:
# valid_input_index: a 1D boolean array indicating where the source lats and lons
# are valid values (not masked)
# valid_output_index: a 1D boolean array indicating where the sector lats and
# lons are valid values (always true everywhere)
# index_array: a 1D array of ints indicating which indicies in the flattened
# inputs should be used to fit the sector lats and lons
# distance_array: Distances from the source point for each found point.
#
# What we do here is feed our data lats/lons to get_neighbour_info.
# We then reshape valid_input_index to fit our lats/lons and find the 2D indicies
# where the input lats and lons were good.
# We then subset the "good" indicies with index_array to retrieve the required
# indicies for the sector.
# This is complicated because get_neighbour_info does not report the indicies of the
# input data, but instead reports the indicies of the flattened data where
# valid_input_index is True
# Get filename for sector indicies
# If the filename format needs to change for the pre-generated geolocation
# files, please discuss prior to changing. It will force recreation of all
# files, which can be problematic for large numbers of sectors
check_geolocation_cache_backend(geolocation_cache_backend)
fname = get_geolocation_cache_filename(
"GEOINDS",
metadata,
area_def,
geolocation_cache_backend=geolocation_cache_backend,
chunk_size=None,
)
fname_partial = fname + ".partial"
if Path(fname_partial.replace("partial", "no_coverage")).exists():
msg = "NO GOOD DATA AVAILABLE, can not read geostationary dataset"
LOG.info(msg)
raise CoverageError(msg)
if resource_tracker is not None:
key = "GEOINDS: " + str(Path(fname).name)
resource_tracker.track_resource_usage(
logstr="MEMUSG", verbose=False, key=key, increment_key=True
)
if not Path(fname).exists() and not Path(fname_partial).exists():
# First touch a file with the partial cache name to identify we're creating the
# cache.
create_empty_partial_cache(fname_partial, geolocation_cache_backend)
if (
area_def is not None
and DONT_AUTOGEN_GEOLOCATION
and "tc2019" not in area_def.area_id
):
msg = (
"GETGEO Requested NO AUTOGEN GEOLOCATION. "
+ "Could not create inds_file {} for {}"
).format(fname, area_def.area_id)
LOG.error(msg)
raise AutoGenError(msg)
# Allocate the full disk area definition
LOG.info(" GETGEOINDS Masking longitudes")
lons = np.ma.masked_less(lons, -999.1)
LOG.info(" GETGEOINDS Wrapping longitudes, pyresample expects -180 to 180")
# NOTE: it appears pyresample wrap_longitudes can sometimes return a
# different dtype than passed in. SwathDefinition will fail if dtype
# does not match between lats and lons. I believe pyresample
# wrap_longitudes began returning different dtype after numpy 2.0
# upgrade. It is probably a bug with pyresample, but this will ensure
# it does not break our processing.
lons = utils.wrap_longitudes(lons).astype(lats.dtype)
LOG.info(
" GETGEOINDS Creating full disk swath definition for {}".format(
area_def.area_id
)
)
fldk_ad = SwathDefinition(
np.ma.masked_less(lons, -999.1), np.ma.masked_less(lats, -999.1)
)
ad = area_def
# Radius of influence will be 10 times the nominal spatial resolution of the
# data in meters
# This uses the only piece of information available concerning resolution
# in the metadata
LOG.info(
" GETGEOINDS Calculating radius of influence {}".format(area_def.area_id)
)
if "res_km" not in metadata.keys():
shape = lons.shape
latres = (
np.abs(
lats[int(shape[0] / 2), int(shape[1] / 2)]
- lats[int(shape[0] / 2 + 1), int(shape[1] / 2)]
)
* 111.1
* 1000
)
lonres = (
np.abs(
lons[int(shape[0] / 2), int(shape[1] / 2)]
- lons[int(shape[0] / 2), int(shape[1] / 2 + 1)]
)
* 111.1
* 1000
)
# Use larger of the two values times 10 as ROI for interpolation
# Would be nice to use something more dynamic to save CPU time here
# Kind of stuck as long as we use pyresample
metadata["res_km"] = max(latres, lonres) / 1000.0
roi = (
metadata["roi_factor"] * 1000.0 * metadata["res_km"]
) # roi_factor * resolution in meters
LOG.info(
" GETGEOINDS Running get_neighbour_info %s roi %s res_km %s "
"roi_factor %s",
area_def.area_id,
roi,
metadata["res_km"],
metadata["roi_factor"],
)
(
valid_input_index,
valid_output_index,
index_array,
distance_array,
) = get_neighbour_info(
fldk_ad, ad, radius_of_influence=roi, neighbours=1, nprocs=nprocs
)
LOG.info(
" GETGEOINDS Getting good lines and samples {}".format(area_def.area_id)
)
good_lines, good_samples = np.where(valid_input_index.reshape(lats.shape))
if len(good_lines) == 0 and len(good_samples) == 0:
# Rename the partial cache and flag as no coverage if it exists.
if Path(fname_partial).exists():
rename_partial_cache(
fname_partial, fname_partial.replace("partial", "no_coverage")
)
raise CoverageError(
"NO GOOD DATA AVAILABLE, can not read geostationary dataset"
)
LOG.info(
" GETGEOINDS Reshaping lines and samples {}".format(area_def.area_id)
)
# When get_neighbour_info does not find a good value for a specific location it
# fills index_array with the maximum index + 1. So, just throw away all of
# the out of range indexes.
index_mask = index_array == len(good_lines)
# good_index_array = index_array[np.where(index_array != len(good_lines))]
lines = np.empty(ad.size, dtype=np.int64)
lines[index_mask] = -999.1
lines[~index_mask] = good_lines[index_array[~index_mask]]
samples = np.empty(ad.size, dtype=np.int64)
samples[index_mask] = -999.1
samples[~index_mask] = good_samples[index_array[~index_mask]]
LOG.info(
" GETGEOINDS Writing to {} : inds_file for {}".format(
fname, area_def.area_id
)
)
if geolocation_cache_backend == "memmap":
# Store indicies for sector
with open(str(fname_partial), "w") as df:
lines.tofile(df)
samples.tofile(df)
# Store indicies for sector
# Possible switch to xarray based geolocation files, but we lose memmapping.
# ds = xarray.Dataset({'lines':(['x'],lines),'samples':(['x'],samples)})
# ds.to_netcdf(fname_partial)
elif geolocation_cache_backend == "zarr":
if chunk_size:
chunks = (chunk_size, chunk_size)
else:
chunks = None
LOG.info("Storing lines/samples to %s (chunks=%s)", fname, chunks)
# NOTE zarr does NOT have a close method, so you can NOT use with context.
zf = zarr.open(fname_partial, mode="w")
# Assume both arrays have the same shape and dtype
kwargs = {
"shape": lines.shape,
"dtype": lines.dtype,
}
# As of Python 3.11, can't pass chunks=None into create_dataset
if chunks:
# Chunks must be a tuple of the same shape as array.
kwargs["chunks"] = tuple([chunk_size] * lines.ndim)
zf.create_dataset("lines", **kwargs)
zf.create_dataset("samples", **kwargs)
zf["lines"][:] = lines
zf["samples"][:] = samples
rename_partial_cache(fname_partial, fname)
else:
check_for_partial_cache(fname, fname_partial, cache_timeout_seconds)
LOG.info(
"GETGEO to %s : inds file for %s, roi_factor %s",
fname,
metadata["scene"],
metadata["roi_factor"],
)
LOG.info(
"GETGEO to %s : lat/lon file for %s, roi_factor %s",
fname,
metadata["scene"],
metadata["roi_factor"],
)
shape = area_def.shape
try:
if geolocation_cache_backend == "memmap":
# Create a memmap to the lat/lon file
# Nothing will be read until explicitly requested
# We are mapping this here so that the lats and lons are available when
# calculating satlelite angles.
# LOG.info(
# 'GETGEO memmap to %s : inds file for %s, roi_factor %s, res_km %s',
# fname,
# metadata['scene'],
# metadata['roi_factor'],
# metadata['res_km']
# )
# LOG.info(
# 'GETGEO memmap to %s : %s file for %s, roi_factor %s, res_km %s',
# fname,
# "lat/lon",
# metadata['scene'],
# metadata['roi_factor'],
# metadata['res_km']
# )
offset = 8 * shape[0] * shape[1]
LOG.info(
"GETGEO memmap from %s : lines for %s, shape %s",
fname,
metadata["scene"],
shape,
)
lines = np.memmap(
fname, mode="r", dtype=np.int64, offset=0, shape=shape
)
LOG.info(
"GETGEO memmap from %s : samples for %s, offset %s",
fname,
metadata["scene"],
offset,
)
samples = np.memmap(
fname, mode="r", dtype=np.int64, offset=offset, shape=shape
)
elif geolocation_cache_backend == "zarr":
# Load lines/samples from pre-calculated zarr
LOG.info(
"GETGEO zarr to {} : inds file for {}".format(
fname, area_def.area_id
)
)
# NOTE zarr does NOT have close method, so you can NOT use with context.
zf = zarr.open(fname, mode="r")
if "lines" not in zf or "samples" not in zf:
LOG.exception(
"lines and samples not in zarr directory, corrupt zarr? "
f"Please remove {fname} and re-run this script."
)
raise RuntimeError(
"lines and samples not in zarr directory, corrupt zarr? "
f"Please remove the directory:\n"
f"{fname}\n"
"and re-run this script."
)
lines = zf["lines"]
samples = zf["samples"]
lines = np.reshape(lines, shape=shape)
samples = np.reshape(samples, shape=shape)
except ValueError as resp:
LOG.warning(
"Mismatched geolocation file size (Empty? No coverage? Or old "
"sector of different shape?"
)
raise CachedGeolocationIndexError(resp)
if resource_tracker is not None:
resource_tracker.track_resource_usage(
logstr="MEMUSG", verbose=False, key=key, increment_key=True
)
# Possible switch to xarray based geolocation files, but we lose memmapping.
# saved_xarray = xarray.load_dataset(fname)
# lines= saved_xarray['lines'].to_masked_array()
# samples = saved_xarray['samples'].to_masked_array()
return lines, samples
[docs]def calculate_solar_angles(
metadata,
lats,
lons,
dt,
resource_tracker=None,
area_def=None,
geolocation_cache_backend=None,
chunk_size=None,
cache_solar_angles=False,
scan_datetime=None,
cache_timeout_seconds=30,
):
"""Calculate solar angles."""
# If debug is set to True, memory savings will be turned off in order to keep
# all calculated results for inspection.
# If set to False, variables will attempt to reuse memory when possible which
# will result in some results being overwritten when no longer needed.
debug = False
if area_def:
adname = area_def.area_id
else:
adname = "None"
if cache_solar_angles:
check_geolocation_cache_backend(geolocation_cache_backend)
prefix = "GEOSOL"
if scan_datetime is not None:
prefix += f"_{scan_datetime.strftime('%Y%m%dT%H%M%S.%fZ')}"
fname = get_geolocation_cache_filename(
prefix,
metadata,
area_def=area_def,
geolocation_cache_backend=geolocation_cache_backend,
chunk_size=chunk_size,
solar_angles=True,
)
fname_partial = fname + ".partial"
cache_exists = Path(fname).exists()
else:
cache_exists = False
LOG.info("Calculating solar zenith and azimuth angles.")
if resource_tracker is not None:
key = f"GEO SOLAR ANGLES: {adname}_{lats.shape[0]}x{lats.shape[1]}".replace(
"None", "ALL"
)
resource_tracker.track_resource_usage(
logstr="MEMUSG", verbose=False, key=key, increment_key=True
)
# Getting good value mask
# good = lats > -999
# good_lats = lats[good]
# good_lons = lons[good]
if cache_solar_angles is False or (cache_solar_angles and cache_exists is False):
if cache_solar_angles and not cache_exists and not Path(fname_partial).exists():
# First touch a file with the partial cache name to identify we're creating
# the cache.
create_empty_partial_cache(fname_partial, geolocation_cache_backend)
# Constants
pi = np.pi
pi2 = 2 * pi # NOQA
num_lines = metadata["num_lines"]
num_samples = metadata["num_samples"]
shape = (num_lines, num_samples) # NOQA
size = num_lines * num_samples # NOQA
deg2rad = pi / 180.0 # NOQA
rad2deg = 180.0 / pi # NOQA
# Calculate any non-data dependent quantities
jday = float(dt.strftime("%j"))
a1 = (1.00554 * jday - 6.28306) * (pi / 180.0)
a2 = (1.93946 * jday - 23.35089) * (pi / 180.0)
et = -7.67825 * np.sin(a1) - 10.09176 * np.sin(a2) # NOQA
# Solar declination radians
LOG.debug("Calculating delta")
delta = deg2rad * 23.4856 * np.sin(np.deg2rad(0.9683 * jday - 78.00878)) # NOQA
# Pre-generate sin and cos of latitude
LOG.debug("Calculating sin and cos")
sin_lat = ne.evaluate("sin(deg2rad * lats)") # NOQA
cos_lat = ne.evaluate("cos(deg2rad * lats)") # NOQA
# Hour angle
LOG.debug("Initializing hour angle")
solar_time = dt.hour + dt.minute / 60.0 + dt.second / 3600.0 # NOQA
h_ang = ne.evaluate(
"deg2rad * ((solar_time + lons / 15.0 + et / 60.0 - 12.0) * 15.0)"
)
# Pre-allocate all required arrays
# This avoids having to allocate them again every time the generator is accessed
LOG.debug("Allocating arrays")
sun_elev = np.empty_like(h_ang)
# Hour angle at all points in radians
LOG.debug("Calculating hour angle")
# Sun elevation
LOG.debug("Calculating sun elevation angle using sin and cos")
ne.evaluate(
"arcsin(sin_lat * sin(delta) + cos_lat * cos(delta) * cos(h_ang))",
out=sun_elev,
) # NOQA
LOG.debug("Calculating caz")
# No longer need sin_lat and this saves 3.7GB
if not debug:
caz = sin_lat
else:
caz = np.empty_like(sin_lat)
ne.evaluate(
"-cos_lat * sin(delta) + sin_lat * cos(delta) * cos(h_ang) / cos(sun_elev)",
out=caz,
) # NOQA
LOG.debug("Calculating az")
# No longer need h_ang and this saves 3.7GB
if not debug:
az = h_ang
else:
az = np.empty_like(h_ang)
ne.evaluate("cos(delta) * sin(h_ang) / cos(sun_elev)", out=az) # NOQA
# No longer need sin_lat and this saves 3.7GB
if not debug:
sun_azm = cos_lat
else:
sun_azm = np.empty_like(cos_lat)
ne.evaluate(
"where(az <= -1, -pi / 2.0, where(az > 1, pi / 2.0, arcsin(az)))",
out=sun_azm,
)
LOG.debug("Calculating solar zenith angle")
# No longer need sun_elev and this saves 3.7GB RAM
if not debug:
sun_zen = sun_elev
else:
sun_zen = np.empty_like(sun_elev)
ne.evaluate("90.0 - rad2deg * sun_elev", out=sun_zen)
LOG.debug("Calculating solar azimuth angle")
ne.evaluate(
"where(caz <= 0, pi - sun_azm,where(az <= 0, 2.0 * pi + sun_azm, sun_azm))",
out=sun_azm,
)
sun_azm += pi
ne.evaluate(
"where(sun_azm > 2.0 * pi, sun_azm - 2.0 * pi, sun_azm)", out=sun_azm
)
# ne.evaluate('where(caz <= 0, pi - sun_azm, sun_azm) + pi', out=sun_azm)
# ne.evaluate('rad2deg * where(sun_azm < 0, sun_azm + pi2, where(sun_azm >= pi2,
# sun_azm - pi2, sun_azm))',
# out=sun_azm)
if cache_solar_angles:
if geolocation_cache_backend == "memmap":
LOG.info("Storing to %s", fname_partial)
with open(fname_partial, "w") as df:
sun_zen.tofile(df)
sun_azm.tofile(df)
elif geolocation_cache_backend == "zarr":
if chunk_size:
chunks = (chunk_size, chunk_size)
else:
chunks = None
LOG.info("Storing solar angles to %s (chunks=%s)", fname, chunks)
# NOTE zarr does NOT have close method, so you can NOT use the context.
zf = zarr.open(fname_partial, mode="w")
# Assume both arrays have the same shape and dtype
kwargs = {
"shape": sun_azm.shape,
"dtype": sun_azm.dtype,
}
# As of Python 3.11, can't pass chunks=None into create_dataset
if chunks:
# Chunks must be a tuple of the same shape as array.
kwargs["chunks"] = tuple([chunk_size] * sun_azm.ndim)
zf.create_dataset("sun_azm", **kwargs)
zf.create_dataset("sun_zen", **kwargs)
zf["sun_azm"][:] = sun_azm
zf["sun_zen"][:] = sun_zen
rename_partial_cache(fname_partial, fname)
LOG.info("Done calculating solar zenith and azimuth angles")
else:
check_for_partial_cache(fname, fname_partial, cache_timeout_seconds)
if geolocation_cache_backend == "memmap":
# Create a memmap to the lat/lon file
# Nothing will be read until explicitly requested
# We are mapping this here so that the lats and lons are available when
# calculating satlelite angles
LOG.info(
"GETGEO memmap to {} : lat/lon file for {}".format(
fname, metadata["scene"]
)
)
shape = (metadata["num_lines"], metadata["num_samples"])
offset = 8 * metadata["num_samples"] * metadata["num_lines"]
sun_zen = np.memmap(
fname, mode="r", dtype=np.float64, offset=0, shape=shape
)
if resource_tracker is not None:
resource_tracker.track_resource_usage(
key=key, checkpoint=True, increment_key=True
)
sun_azm = np.memmap(
fname, mode="r", dtype=np.float64, offset=offset, shape=shape
)
if resource_tracker is not None:
resource_tracker.track_resource_usage(
key=key, checkpoint=True, increment_key=True
)
# Possible switch to xarray based geolocation files, but we lose memmapping.
# saved_xarray = xarray.load_dataset(fname)
# zen = saved_xarray['zeniths'].to_masked_array()
# azm = saved_xarray['azimuths'].to_masked_array()
elif geolocation_cache_backend == "zarr":
LOG.info(
"GETGEO zarr to {} : sun_azm/sun_zen file for {}".format(
fname, metadata["scene"]
)
)
shape = (metadata["num_lines"], metadata["num_samples"])
# chunk_x, chunk_y = [int(x/100) for x in lats.shape]
# NOTE zarr does NOT have a close method, so you can NOT use with context.
zf = zarr.open(fname, mode="r")
sun_azm = zf["sun_azm"]
sun_zen = zf["sun_zen"]
if resource_tracker is not None:
resource_tracker.track_resource_usage(
logstr="MEMUSG", verbose=False, key=key, increment_key=True
)
return sun_zen, sun_azm