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

125 statements  

« prev     ^ index     » next       coverage.py v6.4.2, created at 2022-08-06 02:06 -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/>. 

21 

22from __future__ import annotations 

23 

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) 

39 

40import warnings 

41from abc import ABCMeta, abstractmethod 

42from collections import abc 

43from numbers import Number 

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

45 

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 

52 

53from .contexts import ContextApplier 

54 

55 

56class ScalarMeta(ABCMeta): 

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

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

59 

60 

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. 

64 

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. 

68 

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

72 

73 def __init__(self) -> None: 

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

75 

76 

77Vector = NDArray 

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

79like an NDArray should be considered a Vector. 

80""" 

81 

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

86 

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

92 

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

97 

98 

99class AnalysisAction(ConfigurableAction): 

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

101 

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

105 

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

109 

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. 

114 

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. 

120 

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

127 

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. 

132 

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

140 

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. 

145 

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 

154 

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

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

157 by kwargs passed to this method. 

158 

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 

167 

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

172 

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 ) 

184 

185 

186class KeyedDataAction(AnalysisAction): 

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

188 called. 

189 """ 

190 

191 @abstractmethod 

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

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

194 

195 

196class VectorAction(AnalysisAction): 

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

198 called. 

199 """ 

200 

201 @abstractmethod 

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

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

204 

205 

206class ScalarAction(AnalysisAction): 

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

208 called. 

209 """ 

210 

211 @abstractmethod 

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

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

214 

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. 

218 

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 

228 

229 

230class MetricAction(AnalysisAction): 

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

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

233 """ 

234 

235 @abstractmethod 

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

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

238 

239 

240class PlotAction(AnalysisAction): 

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

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

243 """ 

244 

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 

248 

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

256 

257 @abstractmethod 

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

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

260 

261 

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. 

265 

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

267 `AnalysisMetric` or an `AnalysisPlot`. 

268 

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. 

275 

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

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

278 

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

287 

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

294 

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 

318 

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 

329 

330 def setDefaults(self): 

331 super().setDefaults() 

332 # imported here to avoid circular imports 

333 from .analysisParts.base import BasePrep, BaseProcess 

334 

335 self.prep = BasePrep() 

336 self.process = BaseProcess() 

337 

338 def getInputSchema(self) -> KeyedDataSchema: 

339 self.populatePrepFromProcess() 

340 return self.prep.getInputSchema() 

341 

342 def populatePrepFromProcess(self): 

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

344 

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. 

348 

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

353 

354 

355class AnalysisMetric(AnalysisTool): 

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

357 

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

362 

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

364 

365 def setDefaults(self): 

366 super().setDefaults() 

367 # imported here to avoid circular imports 

368 from .analysisParts.base import BaseMetricAction 

369 

370 self.produce = BaseMetricAction 

371 

372 

373class AnalysisPlot(AnalysisTool): 

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

375 

376 The produce stage of `AnalysisMetric` has been specialized such that 

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

378 """ 

379 

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

381 

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

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

384 

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. 

388 

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