Coverage for python/lsst/analysis/tools/interfaces.py: 55%
125 statements
« prev ^ index » next coverage.py v6.4.2, created at 2022-08-05 01:25 -0700
« prev ^ index » next coverage.py v6.4.2, created at 2022-08-05 01:25 -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 "ScalarAction",
28 "MetricAction",
29 "PlotAction",
30 "Scalar",
31 "KeyedData",
32 "KeyedDataTypes",
33 "KeyedDataSchema",
34 "Vector",
35 "AnalysisTool",
36 "AnalysisMetric",
37 "AnalysisPlot",
38)
40import warnings
41from abc import ABCMeta, abstractmethod
42from collections import abc
43from numbers import Number
44from typing import Any, Iterable, Mapping, MutableMapping, Tuple, Type
46import numpy as np
47from lsst.pex.config import Field
48from lsst.pipe.tasks.configurableActions import ConfigurableAction, ConfigurableActionField
49from lsst.verify import Measurement
50from matplotlib.figure import Figure
51from numpy.typing import NDArray
53from .contexts import ContextApplier
56class ScalarMeta(ABCMeta):
57 def __instancecheck__(cls: ABCMeta, instance: Any) -> Any:
58 return isinstance(instance, tuple(cls.mro()[1:]))
61class Scalar(Number, np.number, metaclass=ScalarMeta):
62 """This is an interface only class, and is intended to abstract around all
63 the various types of numbers used in Python.
65 This has been tried many times with various levels of success in python,
66 and this is another attempt. However, as this class is only intended as an
67 interface, and not something concrete to use it works.
69 Users should not directly instantiate from this class, instead they should
70 use a built in python number type, or a numpy number.
71 """
73 def __init__(self) -> None:
74 raise NotImplementedError("Scalar is only an interface and should not be directly instantiated")
77Vector = NDArray
78"""A Vector is an abstraction around the NDArray interface, things that 'quack'
79like an NDArray should be considered a Vector.
80"""
82KeyedData = MutableMapping[str, Vector | Scalar]
83"""KeyedData is an interface where either a `Vector` or `Scalar` can be
84retrieved using a key which is of str type.
85"""
87KeyedDataTypes = MutableMapping[str, Type[Vector] | Type[Number] | Type[np.number]]
88r"""A mapping of str keys to the Types which are valid in `KeyedData` objects.
89This is useful in conjunction with `AnalysisAction`\ 's ``getInputSchema`` and
90``getOutputSchema`` methods.
91"""
93KeyedDataSchema = Iterable[Tuple[str, Type[Vector] | Type[Number] | Type[np.number]]]
94r"""An interface that represents a type returned by `AnalysisAction`\ 's
95``getInputSchema`` and ``getOutputSchema`` methods.
96"""
99class AnalysisAction(ConfigurableAction):
100 """Base class interface for the various actions used in analysis tools.
102 This extends the basic `ConfigurableAction` class to include interfaces for
103 defining what an action expects to consume, and what it expects to produce.
104 """
106 def __init_subclass__(cls, **kwargs):
107 if "getInputSchema" not in dir(cls): 107 ↛ 108line 107 didn't jump to line 108, because the condition on line 107 was never true
108 raise NotImplementedError(f"Class {cls} must implement method getInputSchema")
110 # This is a descriptor that functions like a function in most contexts
111 # and can be treated as such
112 applyContext = ContextApplier()
113 r"""Apply a `Context` to an `AnalysisAction` recursively.
115 Generally this method is called from within an `AnalysisTool` to
116 configure all `AnalysisAction`\ s at one time to make sure that they
117 all are consistently configured. However, it is permitted to call this
118 method if you are aware of the effects, or from within a specific
119 execution environment like a python shell or notebook.
121 Parameters
122 ----------
123 context : `Context`
124 The specific execution context, this may be a single context or
125 a joint context, see `Context` for more info.
126 """
128 @abstractmethod
129 def getInputSchema(self) -> KeyedDataSchema:
130 """Return the schema an `AnalysisAction` expects to be present in the
131 arguments supplied to the __call__ method.
133 Returns
134 -------
135 result : `KeyedDataSchema`
136 The schema this action requires to be present when calling this
137 action, keys are unformatted.
138 """
139 raise NotImplementedError("This is not implemented on the base class")
141 def getOutputSchema(self) -> KeyedDataSchema | None:
142 """Return the schema an `AnalysisAction` will produce, if the
143 ``__call__`` method returns `KeyedData`, otherwise this may return
144 None.
146 Returns
147 -------
148 result : `KeyedDataSchema` or None
149 The schema this action will produce when returning from call. This
150 will be unformatted if any templates are present. Should return
151 None if action does not return `KeyedData`.
152 """
153 return None
155 def getFormattedInputSchema(self, **kwargs) -> KeyedDataSchema:
156 """Return input schema, with keys formatted with any arguments supplied
157 by kwargs passed to this method.
159 Returns
160 -------
161 result : `KeyedDataSchema`
162 The schema this action requires to be present when calling this
163 action, formatted with any input arguments (e.g. band='i')
164 """
165 for key, typ in self.getInputSchema():
166 yield key.format_map(kwargs), typ
168 def addInputSchema(self, inputSchema: KeyedDataSchema) -> None:
169 """Add the supplied inputSchema argument to the class such that it will
170 be returned along side any other arguments in a call to
171 ``getInputSchema``.
173 Parameters
174 ----------
175 inputSchema : `KeyedDataSchema`
176 A schema that is to be merged in with any existing schema when a
177 call to ``getInputSchema`` is made.
178 """
179 warnings.warn(
180 f"{type(self)} does not implement adding input schemas, call will do nothing, "
181 "this may be expected",
182 RuntimeWarning,
183 )
186class KeyedDataAction(AnalysisAction):
187 """A `KeyedDataAction` is an `AnalysisAction` that returns `KeyedData` when
188 called.
189 """
191 @abstractmethod
192 def __call__(self, data: KeyedData, **kwargs) -> KeyedData:
193 raise NotImplementedError("This is not implemented on the base class")
196class VectorAction(AnalysisAction):
197 """A `VectorAction` is an `AnalysisAction` that returns a `Vector` when
198 called.
199 """
201 @abstractmethod
202 def __call__(self, data: KeyedData, **kwargs) -> Vector:
203 raise NotImplementedError("This is not implemented on the base class")
206class ScalarAction(AnalysisAction):
207 """A `ScalarAction` is an `AnalysisAction` that returns a `Scalar` when
208 called.
209 """
211 @abstractmethod
212 def __call__(self, data: KeyedData, **kwargs) -> Scalar:
213 raise NotImplementedError("This is not implemented on the base class")
215 def getMask(self, **kwargs) -> Vector | slice:
216 """Extract a mask if one is passed as key word args, otherwise return
217 an empty slice object that can still be used in a getitem call.
219 Returns
220 -------
221 result : `Vector` or `slice`
222 The mask passed as a keyword, or a slice object that will return
223 a complete Vector when used in getitem.
224 """
225 if (mask := kwargs.get("mask")) is None:
226 mask = slice(None)
227 return mask
230class MetricAction(AnalysisAction):
231 """A `MetricAction` is an `AnalysisAction` that returns a `Measurement` or
232 a `Mapping` of `str` to `Measurement` when called.
233 """
235 @abstractmethod
236 def __call__(self, data: KeyedData, **kwargs) -> Mapping[str, Measurement] | Measurement:
237 raise NotImplementedError("This is not implemented on the base class")
240class PlotAction(AnalysisAction):
241 """A `PlotAction` is an `AnalysisAction` that returns a `Figure` or
242 a `Mapping` of `str` to `Figure` when called.
243 """
245 def getOutputNames(self) -> Iterable[str]:
246 """Returns a list of names that will be used as keys if this action's
247 call method returns a mapping. Otherwise return an empty Iterable
249 Returns
250 -------
251 result : `Iterable` of `str`
252 If a `PlotAction` produces more than one plot, this should be the
253 keys the action will use in the returned `Mapping`.
254 """
255 return tuple()
257 @abstractmethod
258 def __call__(self, data: KeyedData, **kwargs) -> Mapping[str, Figure] | Figure:
259 raise NotImplementedError("This is not implemented on the base class")
262class AnalysisTool(AnalysisAction):
263 r"""A tool which which calculates a single type of analysis on input data,
264 though it may return more than one result.
266 AnalysisTools should be used though one of its sub-classes, either an
267 `AnalysisMetric` or an `AnalysisPlot`.
269 Although `AnalysisTool`\ s are considered a single type of analysis, the
270 classes themselves can be thought of as a container. `AnalysisTool`\ s
271 are aggregations of `AnalysisAction`\ s to form prep, process, and
272 produce stages. These stages allow better reuse of individual
273 `AnalysisActions` and easier introspection in contexts such as a notebook
274 or interprepter.
276 An `AnalysisTool` can be thought of an an individual configuration that
277 specifies which `AnalysisAction` should run for each stage.
279 The stages themselves are also configurable, allowing control over various
280 aspects of the individual `AnalysisAction`\ s.
281 """
282 prep = ConfigurableActionField[KeyedDataAction](doc="Action to run to prepare inputs")
283 process = ConfigurableActionField[AnalysisAction](
284 doc="Action to process data into intended form",
285 )
286 produce = ConfigurableActionField[AnalysisAction](doc="Action to perform any finalization steps")
288 parameterizedBand: bool | Field[bool] = True
289 """Specifies if an `AnalysisTool` may parameterize a band within any field
290 in any stage, or if the set of bands is already uniquely determined though
291 configuration. I.e. can this `AnalysisTool` be automatically looped over to
292 produce a result for multiple bands.
293 """
295 def __call__(
296 self, data: KeyedData, **kwargs
297 ) -> Mapping[str, Figure] | Figure | Mapping[str, Measurement] | Measurement:
298 bands = kwargs.pop("bands", None)
299 if not self.parameterizedBand or bands is None:
300 if "band" not in kwargs:
301 # Some tasks require a "band" key for naming. This shouldn't
302 # affect the results. DM-35813 should make this unnecessary.
303 kwargs["band"] = "analysisTools"
304 return self._call_single(data, **kwargs)
305 results: dict[str, Any] = {}
306 if self.identity is not None:
307 value_key = f"{{band}}_{self.identity}"
308 else:
309 value_key = "{band}"
310 for band in bands:
311 kwargs["band"] = band
312 match self._call_single(data, **kwargs):
313 case abc.Mapping() as mapping:
314 results.update(mapping.items())
315 case value:
316 results[value_key.format(band=band)] = value
317 return results
319 def _call_single(
320 self, data: KeyedData, **kwargs
321 ) -> Mapping[str, Figure] | Figure | Mapping[str, Measurement] | Measurement:
322 self.populatePrepFromProcess()
323 prepped: KeyedData = self.prep(data, **kwargs) # type: ignore
324 processed: KeyedData = self.process(prepped, **kwargs) # type: ignore
325 finalized: Mapping[str, Figure] | Figure | Mapping[str, Measurement] | Measurement = self.produce(
326 processed, **kwargs
327 ) # type: ignore
328 return finalized
330 def setDefaults(self):
331 super().setDefaults()
332 # imported here to avoid circular imports
333 from .analysisParts.base import BasePrep, BaseProcess
335 self.prep = BasePrep()
336 self.process = BaseProcess()
338 def getInputSchema(self) -> KeyedDataSchema:
339 self.populatePrepFromProcess()
340 return self.prep.getInputSchema()
342 def populatePrepFromProcess(self):
343 """Add additional inputs to the prep stage if supported.
345 If the configured prep action supports adding to it's input schema,
346 attempt to add the required inputs schema from the process stage to the
347 prep stage.
349 This method will be a no-op if the prep action does not support this
350 feature.
351 """
352 self.prep.addInputSchema(self.process.getInputSchema())
355class AnalysisMetric(AnalysisTool):
356 """Specialized `AnalysisTool` for computing metrics.
358 The produce stage of `AnalysisMetric` has been specialized such that
359 it expects to be assigned to a `MetricAction`, and has a default (set in
360 setDefaults) to be `BaseMetricAction`.
361 """
363 produce = ConfigurableActionField[MetricAction](doc="Action which returns a calculated Metric")
365 def setDefaults(self):
366 super().setDefaults()
367 # imported here to avoid circular imports
368 from .analysisParts.base import BaseMetricAction
370 self.produce = BaseMetricAction
373class AnalysisPlot(AnalysisTool):
374 """Specialized `AnalysisTool` for producing plots.
376 The produce stage of `AnalysisMetric` has been specialized such that
377 it expects to be assigned to a `PlotAction`.
378 """
380 produce = ConfigurableActionField[PlotAction](doc="Action which returns a plot")
382 def getOutputNames(self) -> Iterable[str]:
383 """Return the names of the plots produced by this action.
385 This will either come from the `PlotAction` if it defines a
386 ``getOutputNames`` method (likely if it returns a mapping of figures,
387 or a default value is used and a single figure is assumed.
389 Returns
390 -------
391 result : `tuple` of `str`
392 Names for each plot produced by this action.
393 """
394 outNames = tuple(self.produce.getOutputNames())
395 if outNames:
396 return (f"{self.identity or ''}_{name}" for name in outNames)
397 else:
398 if self.parameterizedBand:
399 return (f"{{band}}_{self.identity or ''}",)
400 else:
401 return (f"{self.identity or ''}",)