Source code for autowisp.diagnostics.diagnostic_types

"""The catalogue of per-image diagnostics AutoWISP defines.

The vocabulary of diagnostic names, kept apart from the database that
stores values against them.  Two quite different things want it:

* :func:`autowisp.database.initialize_database._init_diagnostic_types`
  seeds a new project's ``diagnostic_type`` table from it, which is why
  the descriptions live here too rather than only the names.
* :func:`autowisp.diagnostics.expressions.check_expression` needs to know
  what a name in an expression may mean -- and needs to know it *without*
  a project, since a diagnostic expression is defined once and used in
  every project.

That second caller is the reason this module exists.  The catalogue is a
static list: whatever seeds one project seeds them all, so requiring an
open database to discover it was a false dependency.  The database module
is now a consumer of the vocabulary rather than its owner.

The one thing genuinely not knowable in advance is the ``pixel_q*``
family, created at run time by ``calibrate`` rather than seeded.  Those
are a *pattern* rather than a list, and no amount of enumeration would
have captured them -- hence :func:`is_quantile_diagnostic`, which both
the code creating those rows and the code validating expressions against
them ask, so the two cannot drift apart.

Everything here is exposed as a cached function returning an immutable
value rather than as a module-level constant, so that a caller cannot
mutate the catalogue out from under every other caller in the process.
"""

import functools
import re
from types import MappingProxyType


[docs] @functools.lru_cache(maxsize=1) def standard_diagnostic_types(): """ Return every diagnostic seeded into a new project. A mapping rather than a sequence of pairs because that is what the table is: ``DiagnosticType.name`` is unique, so pairs would admit duplicates that only fail later, at insert time. Returns: MappingProxyType: Diagnostic name to its description. Read-only, so it is safe to hand the same object to every caller. """ return MappingProxyType( { "num_extracted_src": ("The number of extracted stars in the image"), **{ f"{param}_center": ( f"The smoothed source extraction {param.upper()} " "parameter at the center of the image" ) for param in ("s", "d", "k") }, **{ f"{param}_map_residual": ( f"RMS difference between source extraction " f"{param.upper()} and smoothed {param.upper()} map" ) for param in ("s", "d", "k") }, "bg_center": ( "The smoothed background level at the center of the image" ), "bg_map_residual": ( "RMS difference between background and smoothed background " "map" ), **{ f"{param}_center": ( f"The {descr} the center of the image according " "to the astrometric solution" ) for param, descr in ( ("ra", "right ascension of"), ("dec", "declination of"), ("z", "zenith distance of"), ) }, "diagonal_fov": ( "The mean angular distance from the image center to its " "four corners on the sky, used as a scale-independent " "measure of the field of view" ), "pointing_offset": ( "The angular distance between the target and the center of " "the image according to the astrometric solution" ), "matched_fraction": ( "The fraction of extracted sources that were matched to " "the reference catalog" ), "astrom_residual": ( "The RMS distance between matched extracted sources and " "their projected positions" ), "srcextract_mag_zeropt": ( "The zeropoint of the transformation between source " "extraction flux and catalog magnitude (the magnitude " "corresponding to a flux of 1 ADU)" ), "magfit_residual": ( "The RMS difference between best fit correction using the " "final master photometric reference." ), "photometry_mag_offset": ( "The best-fit offset between the image magnitude and " "the reference magnitude in magnitude fit." ), "mag_fit_num_stars": ( "The number of stars used in the last magnitude fit " "iteration for this image" ), } )
[docs] @functools.lru_cache(maxsize=1) def standard_diagnostic_names(): """ Return just the names, which is all validating an expression needs. Returns: frozenset: The seeded diagnostic names. """ return frozenset(standard_diagnostic_types())
#: The one diagnostic family created at run time rather than seeded. #: ``calibrate`` records one per configured quantile, so which exist #: depends on how a project was configured and cannot be listed ahead of #: time. Digits are required and the match anchored, so ``pixel_q999`` is #: recognised while a plausible future diagnostic such as ``pixel_quality`` #: is not swallowed. _quantile_pattern = re.compile(r"pixel_q\d+\Z")
[docs] def is_quantile_diagnostic(name): """ Whether *name* is one of the run-time quantile diagnostics. The single definition of what a quantile diagnostic is called. Both the code that creates the rows and the code that validates expressions against them ask here, so the two cannot drift into disagreeing. Args: name(str): The name to test. Returns: bool: Whether ``calibrate`` would record under this name. """ return _quantile_pattern.match(name) is not None
[docs] def is_diagnostic(name): """ Whether *name* is a diagnostic AutoWISP can record, in any project. The complete vocabulary, and knowable without opening a database: a ``diagnostic_type`` row can only come from :func:`standard_diagnostic_types` at project creation or from the quantile branch of ``ImageProcessingManager._save_image_diagnostics``, which refuses every other name. So no project can hold a diagnostic this does not recognise, and validating an expression needs no project. This is *not* the question of whether anything has been recorded here, which is per-project and answered by counting rows. Args: name(str): The name to test. Returns: bool: Whether the name refers to a diagnostic. """ return name in standard_diagnostic_names() or is_quantile_diagnostic(name)
#: The name ``Image.jd`` is plotted and referenced under. Not a diagnostic #: -- it is a column of the image row rather than an ``image_diagnostics`` #: value -- but it is a variable in the same flat name space, and the only #: one that is never NaN, since the canonical image list is defined by #: ``jd IS NOT NULL``. Lives here rather than in ``expression_series`` so #: that validating an expression needs nothing from the database tier. time_quantity = "jd"
[docs] def is_known_quantity(name): """ Whether *name* resolves to data an expression may read. The whole readable vocabulary: every diagnostic, plus the time. This is what tier 1 checks a referenced name against. Args: name(str): The name to test. Returns: bool: Whether the name refers to something readable. """ return name == time_quantity or is_diagnostic(name)
#: The pseudo-quantity offered in place of the individual quantiles, which #: expands to one plotted series per ``pixel_q*`` rather than standing for #: values of its own. Deliberately spelled without digits, so that it does #: not match :func:`is_quantile_diagnostic` -- it names the family, and is #: never one of its members. quantiles_quantity = "pixel_quantiles"
[docs] def is_reserved_name(name): """ Whether something already answers to *name*, so an expression may not. Wider than :func:`is_known_quantity` by exactly one entry, and the asymmetry is the point: :data:`quantiles_quantity` cannot be *read* by an expression, since it stands for a family rather than for values, but neither may it be shadowed by one. Expressions, diagnostics and the family name share one flat name space -- it is what lets a selector and a URL treat them alike -- so a name meaning one thing to the selector and another inside an expression would be ambiguous in both. Args: name(str): The proposed expression name. Returns: bool: Whether the name is taken. """ return name == quantiles_quantity or is_known_quantity(name)