Coverage for python/lsst/analysis/tools/interfaces.py: 48%

125 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-01-12 03:16 -0800

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/>. 

21 

22from __future__ import annotations 

23 

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) 

40 

41import warnings 

42from abc import ABCMeta, abstractmethod 

43from collections import abc 

44from numbers import Number 

45from typing import Any, Iterable, Mapping, MutableMapping, Tuple, Type 

46 

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 

53 

54from .contexts import ContextApplier 

55 

56 

57class ScalarMeta(ABCMeta): 

58 def __instancecheck__(cls: ABCMeta, instance: Any) -> Any: 

59 return isinstance(instance, tuple(cls.mro()[1:])) 

60 

61 

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. 

65 

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. 

69 

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 """ 

73 

74 def __init__(self) -> None: 

75 raise NotImplementedError("Scalar is only an interface and should not be directly instantiated") 

76 

77 

78Vector = NDArray 

79"""A Vector is an abstraction around the NDArray interface, things that 'quack' 

80like an NDArray should be considered a Vector. 

81""" 

82 

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""" 

87 

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""" 

93 

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""" 

98 

99 

100class AnalysisAction(ConfigurableAction): 

101 """Base class interface for the various actions used in analysis tools. 

102 

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 """ 

106 

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") 

110 

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. 

115 

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. 

121 

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 """ 

128 

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. 

133 

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") 

141 

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. 

146 

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 

155 

156 def getFormattedInputSchema(self, **kwargs) -> KeyedDataSchema: 

157 """Return input schema, with keys formatted with any arguments supplied 

158 by kwargs passed to this method. 

159 

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 

168 

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``. 

173 

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 ) 

185 

186 

187class KeyedDataAction(AnalysisAction): 

188 """A `KeyedDataAction` is an `AnalysisAction` that returns `KeyedData` when 

189 called. 

190 """ 

191 

192 @abstractmethod 

193 def __call__(self, data: KeyedData, **kwargs) -> KeyedData: 

194 raise NotImplementedError("This is not implemented on the base class") 

195 

196 

197class VectorAction(AnalysisAction): 

198 """A `VectorAction` is an `AnalysisAction` that returns a `Vector` when 

199 called. 

200 """ 

201 

202 @abstractmethod 

203 def __call__(self, data: KeyedData, **kwargs) -> Vector: 

204 raise NotImplementedError("This is not implemented on the base class") 

205 

206 

207class ScalarAction(AnalysisAction): 

208 """A `ScalarAction` is an `AnalysisAction` that returns a `Scalar` when 

209 called. 

210 """ 

211 

212 @abstractmethod 

213 def __call__(self, data: KeyedData, **kwargs) -> Scalar: 

214 raise NotImplementedError("This is not implemented on the base class") 

215 

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. 

219 

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 

229 

230 

231class MetricAction(AnalysisAction): 

232 """A `MetricAction` is an `AnalysisAction` that returns a `Measurement` or 

233 a `Mapping` of `str` to `Measurement` when called. 

234 """ 

235 

236 @abstractmethod 

237 def __call__(self, data: KeyedData, **kwargs) -> Mapping[str, Measurement] | Measurement: 

238 raise NotImplementedError("This is not implemented on the base class") 

239 

240 

241class PlotAction(AnalysisAction): 

242 """A `PlotAction` is an `AnalysisAction` that returns a `Figure` or 

243 a `Mapping` of `str` to `Figure` when called. 

244 """ 

245 

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 

249 

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() 

257 

258 @abstractmethod 

259 def __call__(self, data: KeyedData, **kwargs) -> Mapping[str, Figure] | Figure: 

260 raise NotImplementedError("This is not implemented on the base class") 

261 

262 

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. 

266 

267 AnalysisTools should be used though one of its sub-classes, either an 

268 `AnalysisMetric` or an `AnalysisPlot`. 

269 

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. 

276 

277 An `AnalysisTool` can be thought of an an individual configuration that 

278 specifies which `AnalysisAction` should run for each stage. 

279 

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") 

288 

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 """ 

295 

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 

319 

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 

330 

331 def setDefaults(self): 

332 super().setDefaults() 

333 # imported here to avoid circular imports 

334 from .analysisParts.base import BasePrep, BaseProcess 

335 

336 self.prep = BasePrep() 

337 self.process = BaseProcess() 

338 

339 def getInputSchema(self) -> KeyedDataSchema: 

340 self.populatePrepFromProcess() 

341 return self.prep.getInputSchema() 

342 

343 def populatePrepFromProcess(self): 

344 """Add additional inputs to the prep stage if supported. 

345 

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. 

349 

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()) 

354 

355 

356class AnalysisMetric(AnalysisTool): 

357 """Specialized `AnalysisTool` for computing metrics. 

358 

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 """ 

363 

364 produce = ConfigurableActionField[MetricAction](doc="Action which returns a calculated Metric") 

365 

366 def setDefaults(self): 

367 super().setDefaults() 

368 # imported here to avoid circular imports 

369 from .analysisParts.base import BaseMetricAction 

370 

371 self.produce = BaseMetricAction 

372 

373 

374class AnalysisPlot(AnalysisTool): 

375 """Specialized `AnalysisTool` for producing plots. 

376 

377 The produce stage of `AnalysisPlot` has been specialized such that 

378 it expects to be assigned to a `PlotAction`. 

379 """ 

380 

381 produce = ConfigurableActionField[PlotAction](doc="Action which returns a plot") 

382 

383 def getOutputNames(self) -> Iterable[str]: 

384 """Return the names of the plots produced by this action. 

385 

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. 

389 

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 ''}",)