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

139 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-02-07 14:40 +0000

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__ = ("AnalysisTool",) 

25 

26from collections.abc import Mapping 

27from functools import wraps 

28from typing import Callable, Iterable, Protocol, runtime_checkable 

29 

30import lsst.pex.config as pexConfig 

31from lsst.pex.config import Field, ListField 

32from lsst.pex.config.configurableActions import ConfigurableActionField 

33from lsst.verify import Measurement 

34 

35from ._actions import AnalysisAction, JointAction, JointResults, NoPlot, PlotAction 

36from ._interfaces import KeyedData, KeyedDataSchema, KeyedResults, PlotTypes 

37from ._stages import BasePrep, BaseProcess, BaseProduce 

38 

39 

40@runtime_checkable 

41class _HasOutputNames(Protocol): 

42 def getOutputNames(self, config: pexConfig.Config | None = None) -> Iterable[str]: ... 42 ↛ exitline 42 didn't return from function 'getOutputNames'

43 

44 

45def _finalizeWrapper( 

46 f: Callable[[AnalysisTool], None], cls: type[AnalysisTool] 

47) -> Callable[[AnalysisTool], None]: 

48 """Wrap a classes finalize function to ensure the base classes special 

49 finalize method only fires after the most derived finalize method. 

50 

51 Parameters 

52 ---------- 

53 f : `Callable` 

54 Function that is being wrapped 

55 cls : `type` of `AnalysisTool` 

56 The class which is having its function wrapped 

57 

58 Returns 

59 ------- 

60 function : `Callable` 

61 The new function which wraps the old 

62 """ 

63 

64 @wraps(f) 

65 def wrapper(self: AnalysisTool) -> None: 

66 # call the wrapped finalize function 

67 f(self) 

68 # get the method resolution order for the self variable 

69 mro = self.__class__.mro() 

70 

71 # Find which class in the mro that last defines a finalize method 

72 # note that this is in the reverse order from the mro, because the 

73 # last class in an inheritance stack is the first in the mro (aka you 

74 # walk from the furthest child first. 

75 # 

76 # Also note that the most derived finalize method need not be the same 

77 # as the type of self, as that might inherit from a parent somewhere 

78 # between it and the furthest parent. 

79 mostDerived: type | None = None 

80 for klass in mro: 

81 # inspect the classes dictionary to see if it specifically defines 

82 # finalize. This is needed because normal lookup will go through 

83 # the mro, but this needs to be restricted to each class. 

84 if "finalize" in vars(klass): 

85 mostDerived = klass 

86 break 

87 

88 # Find what stage in the MRO walking process the recursive function 

89 # call is in. 

90 this = super(cls, self).__thisclass__ 

91 

92 # If the current place in the MRO walking is also the class that 

93 # defines the most derived instance of finalize, then call the base 

94 # classes private finalize that must be called after everything else. 

95 if mostDerived is not None and this == mostDerived: 

96 self._baseFinalize() 

97 

98 return wrapper 

99 

100 

101class AnalysisTool(AnalysisAction): 

102 r"""A tool which which calculates a single type of analysis on input data, 

103 though it may return more than one result. 

104 

105 Although `AnalysisTool`\ s are considered a single type of analysis, the 

106 classes themselves can be thought of as a container. `AnalysisTool`\ s 

107 are aggregations of `AnalysisAction`\ s to form prep, process, and 

108 produce stages. These stages allow better reuse of individual 

109 `AnalysisActions` and easier introspection in contexts such as a notebook 

110 or interpreter. 

111 

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

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

114 

115 The stages themselves are also configurable, allowing control over various 

116 aspects of the individual `AnalysisAction`\ s. 

117 """ 

118 

119 prep = ConfigurableActionField[AnalysisAction](doc="Action to run to prepare inputs", default=BasePrep) 

120 process = ConfigurableActionField[AnalysisAction]( 

121 doc="Action to process data into intended form", default=BaseProcess 

122 ) 

123 produce = ConfigurableActionField[AnalysisAction]( 

124 doc="Action to perform any finalization steps", default=BaseProduce 

125 ) 

126 metric_tags = ListField[str]( 

127 doc="List of tags which will be associated with metric measurement(s)", default=[] 

128 ) 

129 

130 def __init_subclass__(cls: type[AnalysisTool], **kwargs): 

131 super().__init_subclass__(**kwargs) 

132 # Wrap all definitions of the finalize method in a special wrapper that 

133 # ensures that the bases classes private finalize is called last. 

134 if "finalize" in vars(cls): 

135 cls.finalize = _finalizeWrapper(cls.finalize, cls) 

136 

137 dynamicOutputNames: bool | Field[bool] = False 

138 """Determines whether to grant the ``getOutputNames`` method access to 

139 config parameters. 

140 """ 

141 

142 parameterizedBand: bool | Field[bool] = True 

143 """Specifies if an `AnalysisTool` may parameterize a band within any field 

144 in any stage, or if the set of bands is already uniquely determined though 

145 configuration. I.e. can this `AnalysisTool` be automatically looped over to 

146 produce a result for multiple bands. 

147 """ 

148 

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

150 bands = kwargs.pop("bands", None) 

151 if "plotInfo" in kwargs and kwargs.get("plotInfo") is not None: 

152 if "plotName" not in kwargs["plotInfo"] or kwargs["plotInfo"]["plotName"] is None: 

153 kwargs["plotInfo"]["plotName"] = self.identity 

154 if not self.parameterizedBand or bands is None: 

155 if "band" not in kwargs: 

156 # Some tasks require a "band" key for naming. This shouldn't 

157 # affect the results. DM-35813 should make this unnecessary. 

158 kwargs["band"] = "analysisTools" 

159 return self._call_single(data, **kwargs) 

160 results: KeyedResults = {} 

161 for band in bands: 

162 kwargs["band"] = band 

163 if "plotInfo" in kwargs: 

164 kwargs["plotInfo"]["bands"] = band 

165 subResult = self._call_single(data, **kwargs) 

166 for key, value in subResult.items(): 

167 match value: 

168 case PlotTypes(): 

169 results[f"{band}_{key}"] = value 

170 case Measurement(): 

171 results[key] = value 

172 return results 

173 

174 def _call_single(self, data: KeyedData, **kwargs) -> KeyedResults: 

175 # create a shallow copy of kwargs 

176 kwargs = dict(**kwargs) 

177 kwargs["metric_tags"] = list(self.metric_tags or ()) 

178 prepped: KeyedData = self.prep(data, **kwargs) # type: ignore 

179 processed: KeyedData = self.process(prepped, **kwargs) # type: ignore 

180 finalized: ( 

181 Mapping[str, PlotTypes] | PlotTypes | Mapping[str, Measurement] | Measurement | JointResults 

182 ) = self.produce( 

183 processed, **kwargs 

184 ) # type: ignore 

185 return self._process_single_results(finalized) 

186 

187 def _getPlotType(self) -> str: 

188 match self.produce: 

189 case PlotAction(): 

190 return type(self.produce).__name__ 

191 case JointAction(plot=NoPlot()): 

192 pass 

193 case JointAction(plot=plotter): 

194 return type(plotter).__name__ 

195 

196 return "" 

197 

198 def _process_single_results( 

199 self, 

200 results: Mapping[str, PlotTypes] | PlotTypes | Mapping[str, Measurement] | Measurement | JointResults, 

201 ) -> KeyedResults: 

202 accumulation = {} 

203 suffix = self._getPlotType() 

204 predicate = f"{self.identity}" if self.identity else "" 

205 match results: 

206 case Mapping(): 

207 for key, value in results.items(): 

208 match value: 

209 case PlotTypes(): 

210 iterable = (predicate, key, suffix) 

211 case Measurement(): 

212 iterable = (predicate, key) 

213 refKey = "_".join(x for x in iterable if x) 

214 accumulation[refKey] = value 

215 case PlotTypes(): 

216 refKey = "_".join(x for x in (predicate, suffix) if x) 

217 accumulation[refKey] = results 

218 case Measurement(): 

219 accumulation[f"{predicate}"] = results 

220 case JointResults(plot=plotResults, metric=metricResults): 

221 if plotResults is not None: 

222 subResult = self._process_single_results(plotResults) 

223 accumulation.update(subResult) 

224 if metricResults is not None: 

225 subResult = self._process_single_results(metricResults) 

226 accumulation.update(subResult) 

227 return accumulation 

228 

229 def getInputSchema(self) -> KeyedDataSchema: 

230 return self.prep.getInputSchema() 

231 

232 def populatePrepFromProcess(self): 

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

234 

235 If the configured prep action supports adding to it's input schema, 

236 attempt to add the required inputs schema from the process stage to the 

237 prep stage. 

238 

239 This method will be a no-op if the prep action does not support this 

240 feature. 

241 """ 

242 self.prep.addInputSchema(self.process.getInputSchema()) 

243 

244 def getOutputNames(self, config: pexConfig.Config | None = None) -> Iterable[str]: 

245 """Return the names of the plots produced by this analysis tool. 

246 

247 If there is a `PlotAction` defined in the produce action, these names 

248 will either come from the `PlotAction` if it defines a 

249 ``getOutputNames`` method (likely if it returns a mapping of figures), 

250 or a default value is used and a single figure is assumed. 

251 

252 Parameters 

253 ---------- 

254 config : `lsst.pex.config.Config`, optional 

255 Configuration of the task. This is only used if the output naming 

256 needs to be config-aware. 

257 

258 Returns 

259 ------- 

260 result : `tuple` of `str` 

261 Names for each plot produced by this action. 

262 """ 

263 match self.produce: 

264 case JointAction(plot=NoPlot()): 

265 return tuple() 

266 case _HasOutputNames(): 

267 outNames = tuple(self.produce.getOutputNames(config=config)) 

268 case _: 

269 raise ValueError(f"Unsupported Action type {type(self.produce)} for getting output names") 

270 

271 results = [] 

272 suffix = self._getPlotType() 

273 if self.parameterizedBand: 

274 prefix = "_".join(x for x in ("{band}", self.identity) if x) 

275 else: 

276 prefix = f"{self.identity}" if self.identity else "" 

277 

278 if outNames: 

279 for name in outNames: 

280 results.append("_".join(x for x in (prefix, name, suffix) if x)) 

281 else: 

282 results.append("_".join(x for x in (prefix, suffix) if x)) 

283 return results 

284 

285 def finalize(self) -> None: 

286 """Run any finalization code that depends on configuration being 

287 complete. 

288 """ 

289 pass 

290 

291 def _baseFinalize(self) -> None: 

292 self.populatePrepFromProcess() 

293 

294 def freeze(self): 

295 if not self.__dict__.get("_finalizeRun"): 

296 self.finalize() 

297 self.__dict__["_finalizeRun"] = True 

298 super().freeze() 

299 

300 

301# explicitly wrap the finalize of the base class 

302AnalysisTool.finalize = _finalizeWrapper(AnalysisTool.finalize, AnalysisTool)