################################################################################
# util.py: Utility functions
################################################################################
"""Utility functions for path handling, file I/O, and metadata computations."""
import math
import os
import re
from pathlib import Path
from typing import Any
import cspyce
import numpy as np
import numpy.typing as npt
import pdstable
from filecache import FCPath
import metadata_tools.defs as defs
#===============================================================================
[docs]
def pds_table(label_path: FCPath) -> pdstable.PdsTable:
"""Read a PdsTable from an FCPath object.
Parameters:
label_path: Path to the label file.
Returns:
Table associated with the given label.
"""
local_label_path = label_path.retrieve()
_local_table_path = label_path.with_suffix('.tab').retrieve() # Retrieve table as well
return pdstable.PdsTable(local_label_path)
#===============================================================================
[docs]
def select_dir(tree: FCPath, col: str, vol: str) -> FCPath:
"""Determine the template directory for a given collection and volume.
Parameters:
tree: Base tree path.
col: Collection name.
vol: Volume name.
Returns:
Directory path.
"""
if tree.parts[-1] != col:
return tree / col / vol
return tree / vol
#===============================================================================
[docs]
def get_index_name(tree: FCPath, vol_id: str, index_type: str) -> str:
"""Determine the name of the index file.
Parameters:
tree: Top dir for volume.
vol_id: Volume ID.
index_type: Index type.
Returns:
Index name.
"""
# Name starts with volume id
tree = tree.absolute()
name = vol_id
# Add type if given
if index_type:
name += '_' + index_type
name += '_index'
return name
#===============================================================================
[docs]
def get_template_name(filename: str, volume_id: str, code_dir: FCPath) -> str:
"""Determine the name of the label template.
Parameters:
filename: Name of table or label file.
volume_id: Volume ID to be replaced by the collection name.
code_dir: Directory whose name is the collection name.
Returns:
Index name.
"""
collection = code_dir.name
return filename.replace(volume_id, collection).split('.')[0]
#===============================================================================
[docs]
def parse_template_name(template_name: str) -> tuple[str, str, FCPath]:
"""Determine host and index type from template name of the form:
<host>_<index type>_index
index_type cannot contain underscores.
Parameters:
template_name: Name of template file.
Returns:
Tuple of host name, index type, and the template directory.
"""
base = template_name.split('_index')[0]
parts = base.split('_')
index_type = parts[-1]
host = '_'.join(parts[0:-1])
template_dir = defs.PARENT_DIR / FCPath('hosts') / FCPath(host) / FCPath('templates')
return (host, index_type, template_dir)
#===============================================================================
### move to utilities, why is this not in numpy?
[docs]
def pm(x: float) -> npt.NDArray[np.float64]:
"""Return plus/minus the input.
Parameters:
x: Value to negate.
Returns:
Plus and minus values.
"""
return np.array([x,-x])
#===============================================================================
### move to utilities, why is this not in numpy?
[docs]
def smooth(data: npt.NDArray[np.float64], width: int) -> npt.NDArray[np.float64]:
"""Smooth a curve using a moving box.
Parameters:
data: Data to smooth.
width: Width of smoothing box.
Returns:
Smoothed data.
"""
kernel = np.ones(width)/width
return np.convolve(data, kernel, mode='same')
#===============================================================================
### move to utilities
[docs]
def splitpath(path: FCPath, string: str) -> tuple[FCPath, FCPath]:
"""Split a path at a given string.
Parameters:
path: Path to split.
string: Search string. The path is split at the first occurrence and the
search string is omitted.
Returns:
Tuple of the path portion before the search string and the portion after it.
"""
parts = path.parts
i = parts.index(string)
return (FCPath('').joinpath(*parts[0:i]), FCPath('').joinpath(*parts[i+1:]))
#===============================================================================
[docs]
def get_volume_subdir(path: FCPath, volume_id: str) -> FCPath:
"""Determine the subdirectory of an input file relative to the volume dir.
Parameters:
path: Input path or directory.
volume_id: Volume ID at which to split the path.
Returns:
Final directory in the tree.
"""
return splitpath(path, volume_id)[-1]
# return path.split(volume_id)[-1] ## not currently supported by filecache
#===============================================================================
[docs]
def replace(tree: list[Any], placeholder: str, name: str) -> Any:
"""Return a copy of the tree of objects, with each occurrence of the
placeholder string replaced by the given name. If a dictionary reference is
detected, then it is evaluated.
Parameters:
tree: List containing the tree.
placeholder: Placeholder to replace.
name: Replacement string.
Returns:
New tree with placeholder replaced by name.
"""
new_tree: list[Any] = []
for leaf in tree:
# Main entries: replace placeholder and evaluate dict references
if type(leaf) in (tuple, list):
# replace placeholder
replacement = replace(leaf, placeholder, name)
# evaluate any dictionary references now that placeholders are resolved
lrep = list(replacement)
for i in range(len(lrep)):
if isinstance(lrep[i], str) and '[' in lrep[i]:
lrep[i] = eval(lrep[i]) # nosec B307 - eval of column refs; tracked by issue #110
replacement = tuple(lrep)
new_tree.append(replacement)
# Simple str with placeholders: replace placeholder and add to tree
elif isinstance(leaf, str) and leaf.find(placeholder) != -1:
new_tree.append(leaf.replace(placeholder, name))
# Everything else: add to tree unchanged
else:
new_tree.append(leaf)
if isinstance(tree, tuple):
return tuple(new_tree)
else:
return new_tree
#===============================================================================
[docs]
def replacement_dict(tree: list[Any], placeholder: str, names: list[str]) -> dict[str, Any]:
"""Create a dictionary of copies of the tree of objects, where each
dictionary entry is keyed by a name in the list and returns a copy of the
tree using that name as the replacement.
Parameters:
tree: List containing the tree.
placeholder: Placeholder to replace.
names: List of replacement strings.
Returns:
New dictionary.
"""
result: dict[str, Any] = {}
for name in names:
result[name] = replace(tree, placeholder, name)
return result
#===============================================================================
[docs]
def replacement_fn(dict_name: str, name: str) -> str:
"""Create a replacement-able dictionary reference.
Parameters:
dict_name: Name of dictionary.
name: Dictionary key, which could be a placeholder string.
Returns:
Dictionary reference keyed by possible placeholder name.
"""
return dict_name + '["' + name + '"]'
#===============================================================================
[docs]
def get_volume_glob(col: str) -> str:
"""Build a glob string to match all volumes in a collection.
Parameters:
col: Collection name, e.g., GO_xxxx.
Returns:
Glob string.
"""
parts = col.rsplit('_', 1)
vol_id = parts[1]
id_glob = vol_id.replace('x', '[0-9]')
volume_glob = parts[0] + '_' + id_glob
return volume_glob
#===============================================================================
[docs]
def add_by_base(x_digits: list[int], y_digits: list[int],
bases: list[int]) -> list[int]: ### move to utilities
"""Add numbers represented using the specified bases.
Parameters:
x_digits: Digits (int) representing the first operand.
y_digits: Digits (int) representing the second operand.
bases: Bases (int) for each position.
Returns:
Digits (int) representing the result.
"""
result = [0]*(len(bases)+1)
carry = 0
for i, (x_digit, y_digit, base) in \
enumerate(zip(reversed(x_digits), reversed(y_digits), reversed(bases), strict=False)):
total = x_digit + y_digit + carry
result[i] = total % base
carry = total // base
result[len(bases)] = carry
return list(reversed(result))
#===============================================================================
[docs]
def expandvars(filespec: str | Path | FCPath) -> str | Path | FCPath: ### add to FCPath?
"""Expand environment variables in path.
Parameters:
filespec: Path to expand.
Returns:
Expanded path.
"""
result = filespec
if not isinstance(result, str):
result = result.as_posix()
result = re.sub('://', '<<token>>', result)
result = os.path.expandvars(result)
result = re.sub('<<token>>', '://', result)
if isinstance(filespec, str):
return result
if isinstance(filespec, FCPath):
return FCPath(result)
return Path(result)
#===============================================================================
[docs]
def read_txt_file(filespec: str | Path | FCPath, as_string: bool = False,
terminator: str = '\r\n') -> str | list[str]: ### move to utilities
"""Read a text file, with some options.
Parameters:
filespec: Path to the file to read. Environment variables are expanded.
as_string: If True, the result is returned as a string using the specified
terminator.
terminator: Terminator to use for string return.
Returns:
If as_string is False, the lines of the file with no terminators; if True,
the lines of the file concatenated using the specified terminator.
"""
# Expand environment variables and resolve to absolute path
path = FCPath(expandvars(FCPath(filespec)))
# Read the file
content = path.read_text(encoding='utf-8', newline=terminator)
if as_string:
return content
# Split into list of lines with no terminator
lines = content.split('\n')
if lines[-1] == '':
lines = lines[:-1]
lines = [c.rstrip('\r\n') for c in lines]
return lines
#===============================================================================
[docs]
def write_txt_file(filespec: str | Path | FCPath, content: str | list[str],
terminator: str | None = '\r\n') -> None: ### move to utilities
"""Write a text file, with some options.
Parameters:
filespec: Path to the file to write.
content: Text to write. If list, each element is a line that will be
terminated using the specified terminator. If string, existing
terminators are replaced with the specified terminator.
terminator: Desired line terminator.
"""
# Expand environment variables and resolve to absolute path
path = FCPath(expandvars(FCPath(filespec)))
# Determine terminator
if terminator is None:
if isinstance(content, list):
crlf = content[0].endswith('\r\n')
else:
crlf = content.endswith('\r\n')
terminator = '\r\n' if crlf else '\n'
# Split into list of lines with no terminator
lines = content.split('\n') if not isinstance(content, list) else content
lines = [c.rstrip('\r\n') for c in lines]
# Reconstitute with correct terminator
text = terminator.join(lines) + terminator
# Write file
path.write_text(text, encoding='utf-8')
#===============================================================================
[docs]
def append_txt_file(filespec: str | Path | FCPath, content: str | list[str],
terminator: str | None = '\r\n') -> None: ### move to utilities
"""Append text to a file, with some options.
Parameters:
filespec: Path to the file to write.
content: Text to write. If list, each element is a line that will be
terminated using the specified terminator. If string, existing
terminators are replaced with the specified terminator.
terminator: Desired line terminator.
"""
# Expand environment variables and resolve to absolute path
path = FCPath(expandvars(FCPath(filespec)))
# Determine terminator
if terminator is None:
if isinstance(content, list):
crlf = content[0].endswith('\r\n')
else:
crlf = content.endswith('\r\n')
terminator = '\r\n' if crlf else '\n'
# Split into list of lines with no terminator
lines = content.split('\n') if not isinstance(content, list) else content
lines = [c.rstrip('\r\n') for c in lines]
# Reconstitute with correct terminator
text = terminator.join(lines) + terminator
# Append by rewriting through FCPath: read any existing content, then write it
# back with the new text appended. Going via read_text/write_text (rather than a
# downcast to a local Path opened in append mode) keeps remote paths (gs://,
# s3://, ...) working, where streaming append is unsupported.
try:
existing = path.read_text(encoding='utf-8')
except FileNotFoundError:
existing = ''
path.write_text(existing + text, encoding='utf-8')
#===============================================================================
### move to utilities
[docs]
def rebase(x: int, bases: list[int], ceil: bool = False) -> tuple[list[int], int]:
"""Convert a decimal number to a different base.
Parameters:
x: Number to convert.
bases: Base (int) to use for each decimal place.
ceil: If True, round each digit up rather than truncating.
Returns:
Tuple of the digits (int) in the new base and the remaining quantity, if any,
exceeding the maximum value that can be represented by the given bases.
"""
digits = []
for base in reversed(bases):
digit = x % base
if not ceil:
digit = int(digit)
else:
digit = math.ceil(digit)
digits.append(digit)
x //= base
return (list(reversed(digits)), x)
#===============================================================================
[docs]
def sclk_split_count(count: str, delim: str | None = None) -> list[int]:
"""Parse a spacecraft clock count into a list.
Parameters:
count: Number to convert.
delim: Field delimiter to use. If None, all non-alphanumeric characters are
treated as delimiters.
Returns:
Fields (int) of the given spacecraft clock count.
"""
# Replace all non-alphanumerics with default delimiter if non given
if delim is None:
delim = '.'
delims = list({c for c in count if not c.isalnum()})
table = {ord(d): ord(delim) for d in delims}
count = count.translate(table)
# Split the count string
fields = list(map(int, (count.split(delim))))
fields = fields + [0, 0, 0, 0]
return fields[0:4]
#===============================================================================
#===============================================================================
[docs]
def sclk_to_ticks(sclk: str, sc: int) -> Any:
"""Convert spacecraft clock count string to ticks.
Parameters:
sclk: Spacecraft clock count string.
sc: NAIF spacecraft identifier.
Returns:
Spacecraft clock ticks.
"""
return cspyce.sctiks_alias(sc, sclk)
#===============================================================================
[docs]
def get_observation_id(observation: Any) -> str:
"""Determine the observation ID for an observation.
Parameters:
observation: Observation object.
Returns:
Observation ID.
"""
return str(observation.subfields['dict']['OBSERVATION_ID'])
#===============================================================================
[docs]
def convert_mission_table(table: list[Any], sc: int) -> list[Any]:
"""Convert mission table SCLK count string to ticks using sclk_to_ticks().
Parameters:
table: Systems table.
sc: NAIF spacecraft identifier.
Returns:
Converted mission table containing ticks instead of strings.
"""
new_table: list[Any] = []
for item in table:
new_table.append(
((sclk_to_ticks(item[1][0], sc),
sclk_to_ticks(item[1][1], sc)),
item[2], item[3], item[4], item[5], item[6]))
return new_table
#===============================================================================
[docs]
def range_of_n_angles(n: int, prob: float = 0.1, tests: int = 100000) -> np.float64:
"""Study the statistics of n randomly chosen angles.
Used to compute the NINETY_PERCENT_RANGE_DEGREES table below. For a set of n
randomly chosen angles 0-360, return the interval such that the likelihood of
all n angles falling within this interval of one another has the given
probability. Base this on the specified number of tests.
Parameters:
n: Number of random samples to analyze.
prob: Probability criterion.
tests: Number of tests to perform.
Returns:
Angular interval in degrees.
"""
max_diffs = []
for _k in range(tests):
values = np.random.rand(n) * 360.
values = np.sort(values % 360)
diffs = np.empty(values.size)
diffs[:-1] = values[1:] - values[:-1]
diffs[-1] = values[0] + 360. - values[-1]
max_diffs.append(diffs.max())
sorted_diffs = np.sort(max_diffs)
cutoff = int((1.-prob) * tests + 0.5)
return np.float64(360. - sorted_diffs[cutoff])
################################################################################
# This is a tabulation of range_of_n_angles(N) for N in the range 0-1000
# We have 90% confidence that, if the N angles fall within this range, then the
# points do not sample a full 360 degrees of longitude.
################################################################################
NINETY_PERCENT_RANGE_DEGREES = np.array([
0.000, 0.000, 18.000, 65.682, 105.260, 135.335, 158.717, 177.366, 192.527, 205.134,
215.648, 225.089, 232.935, 239.913, 246.178, 251.693, 256.834, 261.349, 265.279, 269.092,
272.471, 275.603, 278.550, 281.247, 283.765, 286.168, 288.339, 290.340, 292.325, 294.165,
295.864, 297.473, 298.976, 300.490, 301.843, 303.207, 304.438, 305.556, 306.733, 307.824,
308.847, 309.884, 310.841, 311.734, 312.588, 313.447, 314.264, 315.094, 315.822, 316.550,
317.261, 317.970, 318.580, 319.220, 319.836, 320.457, 321.044, 321.560, 322.104, 322.608,
323.119, 323.788, 324.007, 324.891, 325.153, 325.444, 325.846, 326.399, 326.476, 327.109,
327.315, 327.836, 328.114, 328.482, 328.806, 329.285, 329.764, 329.890, 330.145, 330.617,
330.905, 331.254, 331.386, 331.701, 332.006, 332.473, 332.484, 332.936, 333.199, 333.220,
333.744, 333.740, 334.021, 334.347, 334.438, 334.794, 335.075, 335.232, 335.284, 335.611,
335.909, 335.903, 336.214, 336.544, 336.682, 336.804, 336.868, 337.071, 337.370, 337.528,
337.688, 337.753, 337.782, 338.255, 338.482, 338.519, 338.627, 338.681, 339.024, 339.043,
339.346, 339.397, 339.602, 339.700, 339.894, 340.027, 340.092, 340.214, 340.468, 340.584,
340.538, 340.687, 340.808, 341.034, 340.884, 341.211, 341.394, 341.394, 341.682, 341.751,
341.812, 341.898, 342.161, 342.244, 342.359, 342.342, 342.426, 342.572, 342.702, 342.626,
342.873, 343.100, 342.874, 343.032, 343.311, 343.285, 343.471, 343.518, 343.663, 343.694,
343.727, 343.811, 344.054, 344.033, 344.099, 344.076, 344.212, 344.280, 344.473, 344.431,
344.579, 344.582, 344.826, 344.849, 344.979, 344.987, 344.990, 345.085, 345.126, 345.149,
345.264, 345.381, 345.464, 345.579, 345.546, 345.668, 345.718, 345.778, 345.800, 345.889,
346.031, 345.977, 346.119, 346.129, 346.240, 346.259, 346.308, 346.400, 346.372, 346.454,
346.527, 346.679, 346.624, 346.738, 346.772, 346.873, 346.938, 346.972, 347.079, 347.084,
347.144, 347.174, 347.195, 347.298, 347.348, 347.385, 347.464, 347.477, 347.494, 347.526,
347.618, 347.618, 347.743, 347.709, 347.852, 347.849, 347.970, 347.891, 348.000, 348.009,
348.070, 348.135, 348.163, 348.210, 348.183, 348.348, 348.448, 348.324, 348.471, 348.483,
348.515, 348.584, 348.621, 348.644, 348.651, 348.800, 348.774, 348.882, 348.885, 348.911,
348.823, 348.969, 348.892, 348.970, 349.010, 349.121, 349.142, 349.193, 349.185, 349.329,
349.318, 349.306, 349.408, 349.338, 349.416, 349.480, 349.479, 349.514, 349.616, 349.652,
349.652, 349.621, 349.718, 349.735, 349.698, 349.778, 349.743, 349.888, 349.903, 349.864,
349.951, 349.985, 349.937, 350.090, 350.021, 350.043, 350.226, 350.148, 350.174, 350.273,
350.259, 350.245, 350.319, 350.264, 350.412, 350.421, 350.456, 350.414, 350.506, 350.563,
350.593, 350.557, 350.614, 350.621, 350.620, 350.701, 350.669, 350.734, 350.754, 350.773,
350.878, 350.850, 350.896, 350.863, 350.884, 350.942, 350.934, 351.004, 350.975, 351.065,
351.036, 351.082, 351.087, 351.130, 351.167, 351.172, 351.161, 351.213, 351.284, 351.262,
351.201, 351.353, 351.349, 351.336, 351.372, 351.395, 351.425, 351.426, 351.492, 351.507,
351.555, 351.588, 351.578, 351.581, 351.631, 351.598, 351.621, 351.619, 351.692, 351.672,
351.766, 351.699, 351.733, 351.759, 351.733, 351.830, 351.862, 351.907, 351.885, 351.869,
351.982, 351.935, 351.999, 351.965, 352.035, 351.998, 352.025, 352.090, 352.072, 352.087,
352.087, 352.122, 352.164, 352.123, 352.163, 352.143, 352.175, 352.218, 352.223, 352.352,
352.307, 352.345, 352.352, 352.355, 352.346, 352.399, 352.362, 352.364, 352.445, 352.496,
352.473, 352.485, 352.467, 352.530, 352.562, 352.551, 352.609, 352.619, 352.548, 352.652,
352.612, 352.653, 352.671, 352.702, 352.767, 352.699, 352.759, 352.734, 352.752, 352.769,
352.798, 352.759, 352.800, 352.862, 352.890, 352.900, 352.864, 352.949, 352.938, 352.921,
352.975, 352.972, 352.977, 353.005, 352.975, 353.040, 352.997, 353.078, 353.059, 353.064,
353.019, 353.110, 353.101, 353.141, 353.196, 353.161, 353.200, 353.176, 353.199, 353.178,
353.252, 353.253, 353.271, 353.284, 353.272, 353.286, 353.321, 353.322, 353.331, 353.322,
353.396, 353.328, 353.389, 353.370, 353.368, 353.411, 353.410, 353.460, 353.409, 353.483,
353.475, 353.515, 353.497, 353.492, 353.573, 353.586, 353.509, 353.601, 353.585, 353.590,
353.578, 353.574, 353.618, 353.645, 353.608, 353.674, 353.692, 353.696, 353.684, 353.697,
353.701, 353.728, 353.733, 353.765, 353.785, 353.782, 353.777, 353.810, 353.779, 353.788,
353.782, 353.835, 353.859, 353.864, 353.868, 353.875, 353.883, 353.922, 353.929, 353.952,
353.937, 353.948, 353.981, 353.971, 353.933, 353.988, 354.019, 354.054, 354.000, 354.025,
354.053, 354.067, 354.051, 354.050, 354.071, 354.076, 354.163, 354.124, 354.113, 354.122,
354.145, 354.173, 354.168, 354.187, 354.199, 354.210, 354.252, 354.224, 354.232, 354.238,
354.235, 354.219, 354.254, 354.283, 354.275, 354.265, 354.289, 354.317, 354.341, 354.318,
354.366, 354.338, 354.330, 354.403, 354.339, 354.399, 354.377, 354.389, 354.405, 354.435,
354.422, 354.420, 354.454, 354.463, 354.486, 354.481, 354.462, 354.485, 354.461, 354.508,
354.520, 354.522, 354.513, 354.560, 354.523, 354.534, 354.585, 354.553, 354.572, 354.562,
354.600, 354.564, 354.596, 354.642, 354.603, 354.625, 354.621, 354.640, 354.670, 354.661,
354.686, 354.655, 354.701, 354.674, 354.680, 354.699, 354.731, 354.732, 354.742, 354.741,
354.778, 354.776, 354.768, 354.767, 354.792, 354.820, 354.778, 354.798, 354.828, 354.844,
354.826, 354.854, 354.850, 354.835, 354.868, 354.870, 354.888, 354.905, 354.871, 354.911,
354.898, 354.902, 354.901, 354.974, 354.943, 354.951, 354.938, 354.973, 354.968, 354.979,
354.997, 354.989, 355.002, 355.006, 355.038, 355.027, 355.043, 355.051, 354.994, 355.045,
355.048, 355.048, 355.048, 355.059, 355.043, 355.104, 355.091, 355.122, 355.120, 355.099,
355.099, 355.123, 355.155, 355.150, 355.118, 355.152, 355.176, 355.190, 355.124, 355.175,
355.197, 355.170, 355.184, 355.256, 355.212, 355.236, 355.227, 355.221, 355.213, 355.260,
355.277, 355.257, 355.278, 355.261, 355.280, 355.256, 355.309, 355.314, 355.290, 355.308,
355.307, 355.331, 355.315, 355.336, 355.323, 355.335, 355.349, 355.376, 355.341, 355.400,
355.357, 355.350, 355.366, 355.379, 355.398, 355.374, 355.409, 355.422, 355.406, 355.433,
355.447, 355.447, 355.426, 355.459, 355.452, 355.475, 355.456, 355.471, 355.494, 355.496,
355.483, 355.505, 355.495, 355.478, 355.517, 355.518, 355.530, 355.538, 355.551, 355.530,
355.535, 355.572, 355.569, 355.543, 355.589, 355.555, 355.607, 355.586, 355.634, 355.578,
355.604, 355.624, 355.616, 355.610, 355.629, 355.643, 355.629, 355.630, 355.634, 355.649,
355.677, 355.650, 355.679, 355.658, 355.657, 355.690, 355.703, 355.686, 355.703, 355.694,
355.714, 355.742, 355.729, 355.705, 355.721, 355.712, 355.741, 355.736, 355.768, 355.781,
355.727, 355.758, 355.771, 355.794, 355.774, 355.794, 355.789, 355.772, 355.783, 355.807,
355.804, 355.807, 355.828, 355.822, 355.838, 355.836, 355.820, 355.841, 355.840, 355.851,
355.840, 355.857, 355.859, 355.889, 355.873, 355.887, 355.896, 355.876, 355.896, 355.936,
355.896, 355.891, 355.934, 355.940, 355.934, 355.918, 355.927, 355.935, 355.921, 355.950,
355.976, 355.998, 355.955, 355.937, 355.963, 355.984, 355.979, 355.969, 355.991, 355.980,
355.994, 355.972, 355.999, 355.995, 355.989, 356.010, 356.025, 355.989, 356.038, 356.045,
356.026, 356.042, 356.054, 356.038, 356.073, 356.058, 356.067, 356.074, 356.063, 356.077,
356.091, 356.087, 356.116, 356.089, 356.103, 356.096, 356.124, 356.106, 356.126, 356.109,
356.127, 356.101, 356.120, 356.132, 356.133, 356.141, 356.153, 356.162, 356.143, 356.142,
356.167, 356.192, 356.168, 356.176, 356.170, 356.189, 356.167, 356.194, 356.188, 356.190,
356.201, 356.189, 356.219, 356.216, 356.235, 356.233, 356.224, 356.218, 356.225, 356.257,
356.248, 356.240, 356.244, 356.237, 356.261, 356.267, 356.293, 356.262, 356.275, 356.286,
356.292, 356.293, 356.287, 356.306, 356.307, 356.297, 356.306, 356.300, 356.333, 356.310,
356.329, 356.338, 356.302, 356.337, 356.314, 356.330, 356.336, 356.348, 356.335, 356.361,
356.355, 356.377, 356.350, 356.389, 356.352, 356.380, 356.366, 356.378, 356.396, 356.382,
356.402, 356.374, 356.381, 356.402, 356.406, 356.414, 356.390, 356.430, 356.424, 356.428,
356.429, 356.440, 356.445, 356.441, 356.450, 356.463, 356.461, 356.439, 356.448, 356.462,
356.451, 356.479, 356.471, 356.449, 356.495, 356.476, 356.486, 356.473, 356.479, 356.518,
356.494, 356.492, 356.507, 356.494, 356.509, 356.513, 356.544, 356.509, 356.511, 356.504,
356.517, 356.541, 356.526, 356.527, 356.542, 356.536, 356.553, 356.548, 356.553, 356.537,
356.534, 356.557, 356.587, 356.563, 356.583, 356.588, 356.593, 356.582, 356.611, 356.583,
356.589, 356.604, 356.585, 356.569, 356.598, 356.615, 356.598, 356.620, 356.624, 356.624,
356.620, 356.625, 356.644, 356.627, 356.630, 356.651, 356.637, 356.640, 356.666, 356.651,
356.661, 356.664, 356.679, 356.655, 356.638, 356.659, 356.674, 356.681, 356.672, 356.675,
356.677, 356.687, 356.691, 356.685, 356.701, 356.712, 356.701, 356.696, 356.703, 356.717,
])
#===============================================================================
def _ninety_percent_gap_degrees(n: int, scale: float = 1.) -> float:
"""For n samples, determine the approximate number of degrees for the largest
gap in coverage providing 90% confidence that the angular coverage is not
actually complete.
Parameters:
n: Number of samples.
scale: Scale factor for result.
Returns:
Estimated number of degrees.
"""
# Below 1000, use the tabulation
if n < 1000:
return float((360. - NINETY_PERCENT_RANGE_DEGREES[n]) * scale)
# Otherwise, this empirical fit does a good job
return float((1808. * n**(-0.912)) * scale)
#===============================================================================
def _get_range_mod360(values: list[float] | npt.NDArray[np.float64],
alt_format: str | None = None, width: int = 0,
diffmin: float = 0) -> list[float]:
"""Determine the minimum and maximum values in the array, allowing for the
possibility that the numeric range wraps around from 360 to 0.
Parameters:
values: The set of values for which to determine the range.
alt_format: "-180" to return values in the range (-180,180) rather than
(0,360).
width: If given, smoothing width for diffs.
diffmin: Minimum diff to consider. If the maximum diff is below this value,
then full coverage is assumed, unless the span of the given angles is
smaller.
Returns:
Minimum and maximum values in the cyclic array.
"""
# Check for use of negative values
use_minus_180 = (alt_format == "-180")
complete_coverage = [-180., 180.] if use_minus_180 else [0., 360.]
# Flatten the set of values
values = np.asarray(values).flatten()
# With no values, nothing constrains the range; report full coverage.
if values.size == 0:
return complete_coverage
# With only one value, we know nothing
if values.size == 1:
return [values[0], values[0]]
# Locate the largest gap in coverage
values = np.sort(values % 360)
diffs = np.empty(values.size)
diffs[:-1] = values[1:] - values[:-1]
diffs[-1] = values[0] + 360. - values[-1]
span = 360 - diffs[-1]
# Smooth the diffs to remove noise from subsampling.
wdiffs = diffs
if width > 1:
wdiffs = smooth(diffs, width)
# Locate the largest gap and use it to define the range
gap_index = np.argmax(diffs)
range_mod360 = [values[(gap_index + 1) % values.size], values[gap_index]]
diff_max = wdiffs[gap_index]
# Convert to range -180 to 180 if necessary
if use_minus_180:
(lower, upper) = range_mod360
lower = (lower + 180.) % 360. - 180.
upper = (upper + 180.) % 360. - 180.
range_mod360 = [lower, upper]
# Return full coverage if max diff is below specified threshold.
if span > diffmin and diff_max <= diffmin:
return complete_coverage
# Otherwise check 90% confidence that the coverage is not complete.
if diff_max >= _ninety_percent_gap_degrees(values.size):
return range_mod360
# Otherwise, return the complete range
return complete_coverage