Coverage for python/lsst/analysis/tools/interfaces.py: 55%
125 statements
« prev ^ index » next coverage.py v6.4.4, created at 2022-09-12 01:29 -0700
« prev ^ index » next coverage.py v6.4.4, created at 2022-09-12 01:29 -0700
1# This file is part of analysis_tools.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22from __future__ import annotations
24__all__ = (
25 "AnalysisAction",
26 "KeyedDataAction",
27 "VectorAction",
28 "ScalarAction",
29 "MetricAction",
30 "PlotAction",
31 "Scalar",
32 "KeyedData",
33 "KeyedDataTypes",
34 "KeyedDataSchema",
35 "Vector",
36 "AnalysisTool",
37 "AnalysisMetric",
38 "AnalysisPlot",
39)
41import warnings
42from abc import ABCMeta, abstractmethod
43from collections import abc
44from numbers import Number
45from typing import Any, Iterable, Mapping, MutableMapping, Tuple, Type
47import numpy as np
48from lsst.pex.config import Field
49from lsst.pipe.tasks.configurableActions import ConfigurableAction, ConfigurableActionField
50from lsst.verify import Measurement
51from matplotlib.figure import Figure
52from numpy.typing import NDArray
54from .contexts import ContextApplier
57class ScalarMeta(ABCMeta):
58 def __instancecheck__(cls: ABCMeta, instance: Any) -> Any:
59 return isinstance(instance, tuple(cls.mro()[1:]))
62class Scalar(Number, np.number, metaclass=ScalarMeta):
63 """This is an interface only class, and is intended to abstract around all
64 the various types of numbers used in Python.
66 This has been tried many times with various levels of success in python,
67 and this is another attempt. However, as this class is only intended as an
68 interface, and not something concrete to use it works.
70 Users should not directly instantiate from this class, instead they should
71 use a built in python number type, or a numpy number.
72 """
74 def __init__(self) -> None:
75 raise NotImplementedError("Scalar is only an interface and should not be directly instantiated")
78Vector = NDArray
79"""A Vector is an abstraction around the NDArray interface, things that 'quack'
80like an NDArray should be considered a Vector.
81"""
83KeyedData = MutableMapping[str, Vector | Scalar]
84"""KeyedData is an interface where either a `Vector` or `Scalar` can be
85retrieved using a key which is of str type.
86"""
88KeyedDataTypes = MutableMapping[str, Type[Vector] | Type[Number] | Type[np.number]]
89r"""A mapping of str keys to the Types which are valid in `KeyedData` objects.
90This is useful in conjunction with `AnalysisAction`\ 's ``getInputSchema`` and
91``getOutputSchema`` methods.
92"""
94KeyedDataSchema = Iterable[Tuple[str, Type[Vector] | Type[Number] | Type[np.number]]]
95r"""An interface that represents a type returned by `AnalysisAction`\ 's
96``getInputSchema`` and ``getOutputSchema`` methods.
97"""
100class AnalysisAction(ConfigurableAction):
101 """Base class interface for the various actions used in analysis tools.
103 This extends the basic `ConfigurableAction` class to include interfaces for
104 defining what an action expects to consume, and what it expects to produce.
105 """
107 def __init_subclass__(cls, **kwargs):
108 if "getInputSchema" not in dir(cls): 108 ↛ 109line 108 didn't jump to line 109, because the condition on line 108 was never true
109 raise NotImplementedError(f"Class {cls} must implement method getInputSchema")
111 # This is a descriptor that functions like a function in most contexts
112 # and can be treated as such
113 applyContext = ContextApplier()
114 r"""Apply a `Context` to an `AnalysisAction` recursively.
116 Generally this method is called from within an `AnalysisTool` to
117 configure all `AnalysisAction`\ s at one time to make sure that they
118 all are consistently configured. However, it is permitted to call this
119 method if you are aware of the effects, or from within a specific
120 execution environment like a python shell or notebook.
122 Parameters
123 ----------
124 context : `Context`
125 The specific execution context, this may be a single context or
126 a joint context, see `Context` for more info.
127 """
129 @abstractmethod
130 def getInputSchema(self) -> KeyedDataSchema:
131 """Return the schema an `AnalysisAction` expects to be present in the
132 arguments supplied to the __call__ method.
134 Returns
135 -------
136 result : `KeyedDataSchema`
137 The schema this action requires to be present when calling this
138 action, keys are unformatted.
139 """
140 raise NotImplementedError("This is not implemented on the base class")
142 def getOutputSchema(self) -> KeyedDataSchema | None:
143 """Return the schema an `AnalysisAction` will produce, if the
144 ``__call__`` method returns `KeyedData`, otherwise this may return
145 None.
147 Returns
148 -------
149 result : `KeyedDataSchema` or None
150 The schema this action will produce when returning from call. This
151 will be unformatted if any templates are present. Should return
152 None if action does not return `KeyedData`.
153 """
154 return None
156 def getFormattedInputSchema(self, **kwargs) -> KeyedDataSchema:
157 """Return input schema, with keys formatted with any arguments supplied
158 by kwargs passed to this method.
160 Returns
161 -------
162 result : `KeyedDataSchema`
163 The schema this action requires to be present when calling this
164 action, formatted with any input arguments (e.g. band='i')
165 """
166 for key, typ in self.getInputSchema():
167 yield key.format_map(kwargs), typ
169 def addInputSchema(self, inputSchema: KeyedDataSchema) -> None:
170 """Add the supplied inputSchema argument to the class such that it will
171 be returned along side any other arguments in a call to
172 ``getInputSchema``.
174 Parameters
175 ----------
176 inputSchema : `KeyedDataSchema`
177 A schema that is to be merged in with any existing schema when a
178 call to ``getInputSchema`` is made.
179 """
180 warnings.warn(
181 f"{type(self)} does not implement adding input schemas, call will do nothing, "
182 "this may be expected",
183 RuntimeWarning,
184 )
187class KeyedDataAction(AnalysisAction):
188 """A `KeyedDataAction` is an `AnalysisAction` that returns `KeyedData` when
189 called.
190 """
192 @abstractmethod
193 def __call__(self, data: KeyedData, **kwargs) -> KeyedData:
194 raise NotImplementedError("This is not implemented on the base class")
197class VectorAction(AnalysisAction):
198 """A `VectorAction` is an `AnalysisAction` that returns a `Vector` when
199 called.
200 """
202 @abstractmethod
203 def __call__(self, data: KeyedData, **kwargs) -> Vector:
204 raise NotImplementedError("This is not implemented on the base class")
207class ScalarAction(AnalysisAction):
208 """A `ScalarAction` is an `AnalysisAction` that returns a `Scalar` when
209 called.
210 """
212 @abstractmethod
213 def __call__(self, data: KeyedData, **kwargs) -> Scalar:
214 raise NotImplementedError("This is not implemented on the base class")
216 def getMask(self, **kwargs) -> Vector | slice:
217 """Extract a mask if one is passed as key word args, otherwise return
218 an empty slice object that can still be used in a getitem call.
220 Returns
221 -------
222 result : `Vector` or `slice`
223 The mask passed as a keyword, or a slice object that will return
224 a complete Vector when used in getitem.
225 """
226 if (mask := kwargs.get("mask")) is None:
227 mask = slice(None)
228 return mask
231class MetricAction(AnalysisAction):
232 """A `MetricAction` is an `AnalysisAction` that returns a `Measurement` or
233 a `Mapping` of `str` to `Measurement` when called.
234 """
236 @abstractmethod
237 def __call__(self, data: KeyedData, **kwargs) -> Mapping[str, Measurement] | Measurement:
238 raise NotImplementedError("This is not implemented on the base class")
241class PlotAction(AnalysisAction):
242 """A `PlotAction` is an `AnalysisAction` that returns a `Figure` or
243 a `Mapping` of `str` to `Figure` when called.
244 """
246 def getOutputNames(self) -> Iterable[str]:
247 """Returns a list of names that will be used as keys if this action's
248 call method returns a mapping. Otherwise return an empty Iterable
250 Returns
251 -------
252 result : `Iterable` of `str`
253 If a `PlotAction` produces more than one plot, this should be the
254 keys the action will use in the returned `Mapping`.
255 """
256 return tuple()
258 @abstractmethod
259 def __call__(self, data: KeyedData, **kwargs) -> Mapping[str, Figure] | Figure:
260 raise NotImplementedError("This is not implemented on the base class")
263class AnalysisTool(AnalysisAction):
264 r"""A tool which which calculates a single type of analysis on input data,
265 though it may return more than one result.
267 AnalysisTools should be used though one of its sub-classes, either an
268 `AnalysisMetric` or an `AnalysisPlot`.
270 Although `AnalysisTool`\ s are considered a single type of analysis, the
271 classes themselves can be thought of as a container. `AnalysisTool`\ s
272 are aggregations of `AnalysisAction`\ s to form prep, process, and
273 produce stages. These stages allow better reuse of individual
274 `AnalysisActions` and easier introspection in contexts such as a notebook
275 or interprepter.
277 An `AnalysisTool` can be thought of an an individual configuration that
278 specifies which `AnalysisAction` should run for each stage.
280 The stages themselves are also configurable, allowing control over various
281 aspects of the individual `AnalysisAction`\ s.
282 """
283 prep = ConfigurableActionField[KeyedDataAction](doc="Action to run to prepare inputs")
284 process = ConfigurableActionField[AnalysisAction](
285 doc="Action to process data into intended form",
286 )
287 produce = ConfigurableActionField[AnalysisAction](doc="Action to perform any finalization steps")
289 parameterizedBand: bool | Field[bool] = True
290 """Specifies if an `AnalysisTool` may parameterize a band within any field
291 in any stage, or if the set of bands is already uniquely determined though
292 configuration. I.e. can this `AnalysisTool` be automatically looped over to
293 produce a result for multiple bands.
294 """
296 def __call__(
297 self, data: KeyedData, **kwargs
298 ) -> Mapping[str, Figure] | Figure | Mapping[str, Measurement] | Measurement:
299 bands = kwargs.pop("bands", None)
300 if not self.parameterizedBand or bands is None:
301 if "band" not in kwargs:
302 # Some tasks require a "band" key for naming. This shouldn't
303 # affect the results. DM-35813 should make this unnecessary.
304 kwargs["band"] = "analysisTools"
305 return self._call_single(data, **kwargs)
306 results: dict[str, Any] = {}
307 if self.identity is not None:
308 value_key = f"{{band}}_{self.identity}"
309 else:
310 value_key = "{band}"
311 for band in bands:
312 kwargs["band"] = band
313 match self._call_single(data, **kwargs):
314 case abc.Mapping() as mapping:
315 results.update(mapping.items())
316 case value:
317 results[value_key.format(band=band)] = value
318 return results
320 def _call_single(
321 self, data: KeyedData, **kwargs
322 ) -> Mapping[str, Figure] | Figure | Mapping[str, Measurement] | Measurement:
323 self.populatePrepFromProcess()
324 prepped: KeyedData = self.prep(data, **kwargs) # type: ignore
325 processed: KeyedData = self.process(prepped, **kwargs) # type: ignore
326 finalized: Mapping[str, Figure] | Figure | Mapping[str, Measurement] | Measurement = self.produce(
327 processed, **kwargs
328 ) # type: ignore
329 return finalized
331 def setDefaults(self):
332 super().setDefaults()
333 # imported here to avoid circular imports
334 from .analysisParts.base import BasePrep, BaseProcess
336 self.prep = BasePrep()
337 self.process = BaseProcess()
339 def getInputSchema(self) -> KeyedDataSchema:
340 self.populatePrepFromProcess()
341 return self.prep.getInputSchema()
343 def populatePrepFromProcess(self):
344 """Add additional inputs to the prep stage if supported.
346 If the configured prep action supports adding to it's input schema,
347 attempt to add the required inputs schema from the process stage to the
348 prep stage.
350 This method will be a no-op if the prep action does not support this
351 feature.
352 """
353 self.prep.addInputSchema(self.process.getInputSchema())
356class AnalysisMetric(AnalysisTool):
357 """Specialized `AnalysisTool` for computing metrics.
359 The produce stage of `AnalysisMetric` has been specialized such that
360 it expects to be assigned to a `MetricAction`, and has a default (set in
361 setDefaults) to be `BaseMetricAction`.
362 """
364 produce = ConfigurableActionField[MetricAction](doc="Action which returns a calculated Metric")
366 def setDefaults(self):
367 super().setDefaults()
368 # imported here to avoid circular imports
369 from .analysisParts.base import BaseMetricAction
371 self.produce = BaseMetricAction
374class AnalysisPlot(AnalysisTool):
375 """Specialized `AnalysisTool` for producing plots.
377 The produce stage of `AnalysisMetric` has been specialized such that
378 it expects to be assigned to a `PlotAction`.
379 """
381 produce = ConfigurableActionField[PlotAction](doc="Action which returns a plot")
383 def getOutputNames(self) -> Iterable[str]:
384 """Return the names of the plots produced by this action.
386 This will either come from the `PlotAction` if it defines a
387 ``getOutputNames`` method (likely if it returns a mapping of figures,
388 or a default value is used and a single figure is assumed.
390 Returns
391 -------
392 result : `tuple` of `str`
393 Names for each plot produced by this action.
394 """
395 outNames = tuple(self.produce.getOutputNames())
396 if outNames:
397 return (f"{self.identity or ''}_{name}" for name in outNames)
398 else:
399 if self.parameterizedBand:
400 return (f"{{band}}_{self.identity or ''}",)
401 else:
402 return (f"{self.identity or ''}",)