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

139 statements  

« prev     ^ index     » next       coverage.py v7.4.0, created at 2024-01-10 14:10 +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]: 

43 ... 

44 

45 

46def _finalizeWrapper( 

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

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

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

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

51 

52 Parameters 

53 ---------- 

54 f : `Callable` 

55 Function that is being wrapped 

56 cls : `type` of `AnalysisTool` 

57 The class which is having its function wrapped 

58 

59 Returns 

60 ------- 

61 function : `Callable` 

62 The new function which wraps the old 

63 """ 

64 

65 @wraps(f) 

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

67 # call the wrapped finalize function 

68 f(self) 

69 # get the method resolution order for the self variable 

70 mro = self.__class__.mro() 

71 

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

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

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

75 # walk from the furthest child first. 

76 # 

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

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

79 # between it and the furthest parent. 

80 mostDerived: type | None = None 

81 for klass in mro: 

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

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

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

85 if "finalize" in vars(klass): 

86 mostDerived = klass 

87 break 

88 

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

90 # call is in. 

91 this = super(cls, self).__thisclass__ 

92 

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

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

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

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

97 self._baseFinalize() 

98 

99 return wrapper 

100 

101 

102class AnalysisTool(AnalysisAction): 

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

104 though it may return more than one result. 

105 

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

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

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

109 produce stages. These stages allow better reuse of individual 

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

111 or interpreter. 

112 

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

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

115 

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

117 aspects of the individual `AnalysisAction`\ s. 

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 kwargs["plotInfo"]["plotName"] = self.identity 

153 if not self.parameterizedBand or bands is None: 

154 if "band" not in kwargs: 

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

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

157 kwargs["band"] = "analysisTools" 

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

159 results: KeyedResults = {} 

160 for band in bands: 

161 kwargs["band"] = band 

162 if "plotInfo" in kwargs: 

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

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

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

166 match value: 

167 case PlotTypes(): 

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

169 case Measurement(): 

170 results[key] = value 

171 return results 

172 

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

174 # create a shallow copy of kwargs 

175 kwargs = dict(**kwargs) 

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

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

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

179 finalized: Mapping[str, PlotTypes] | PlotTypes | Mapping[ 

180 str, Measurement 

181 ] | Measurement | JointResults = self.produce( 

182 processed, **kwargs 

183 ) # type: ignore 

184 return self._process_single_results(finalized) 

185 

186 def _getPlotType(self) -> str: 

187 match self.produce: 

188 case PlotAction(): 

189 return type(self.produce).__name__ 

190 case JointAction(plot=NoPlot()): 

191 pass 

192 case JointAction(plot=plotter): 

193 return type(plotter).__name__ 

194 

195 return "" 

196 

197 def _process_single_results( 

198 self, 

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

200 ) -> KeyedResults: 

201 accumulation = {} 

202 suffix = self._getPlotType() 

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

204 match results: 

205 case Mapping(): 

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

207 match value: 

208 case PlotTypes(): 

209 iterable = (predicate, key, suffix) 

210 case Measurement(): 

211 iterable = (predicate, key) 

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

213 accumulation[refKey] = value 

214 case PlotTypes(): 

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

216 accumulation[refKey] = results 

217 case Measurement(): 

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

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

220 if plotResults is not None: 

221 subResult = self._process_single_results(plotResults) 

222 accumulation.update(subResult) 

223 if metricResults is not None: 

224 subResult = self._process_single_results(metricResults) 

225 accumulation.update(subResult) 

226 return accumulation 

227 

228 def getInputSchema(self) -> KeyedDataSchema: 

229 return self.prep.getInputSchema() 

230 

231 def populatePrepFromProcess(self): 

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

233 

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

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

236 prep stage. 

237 

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

239 feature. 

240 """ 

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

242 

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

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

245 

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

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

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

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

250 

251 Parameters 

252 ---------- 

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

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

255 needs to be config-aware. 

256 

257 Returns 

258 ------- 

259 result : `tuple` of `str` 

260 Names for each plot produced by this action. 

261 """ 

262 match self.produce: 

263 case JointAction(plot=NoPlot()): 

264 return tuple() 

265 case _HasOutputNames(): 

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

267 case _: 

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

269 

270 results = [] 

271 suffix = self._getPlotType() 

272 if self.parameterizedBand: 

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

274 else: 

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

276 

277 if outNames: 

278 for name in outNames: 

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

280 else: 

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

282 return results 

283 

284 def finalize(self) -> None: 

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

286 complete. 

287 """ 

288 pass 

289 

290 def _baseFinalize(self) -> None: 

291 self.populatePrepFromProcess() 

292 

293 def freeze(self): 

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

295 self.finalize() 

296 self.__dict__["_finalizeRun"] = True 

297 super().freeze() 

298 

299 

300# explicitly wrap the finalize of the base class 

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