Coverage for python/lsst/obs/base/instrument_tests.py: 53%

116 statements  

« prev     ^ index     » next       coverage.py v7.3.1, created at 2023-09-27 11:09 +0000

1# This file is part of obs_base. 

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 <http://www.gnu.org/licenses/>. 

21 

22"""Helpers for writing tests against subclassses of Instrument. 

23 

24These are not tests themselves, but can be subclassed (plus unittest.TestCase) 

25to get a functional test of an Instrument. 

26""" 

27 

28from __future__ import annotations 

29 

30__all__ = [ 

31 "DummyCam", 

32 "InstrumentTestData", 

33 "InstrumentTests", 

34 "DummyCamYamlWcsFormatter", 

35 "CuratedCalibration", 

36] 

37 

38import abc 

39import dataclasses 

40import json 

41from collections.abc import Sequence 

42from functools import lru_cache 

43from typing import TYPE_CHECKING, Any, ClassVar 

44 

45from lsst.daf.butler import CollectionType, DatasetType, Registry, RegistryConfig 

46from lsst.daf.butler.formatters.yaml import YamlFormatter 

47from lsst.obs.base import FilterDefinition, FilterDefinitionCollection, Instrument 

48from lsst.obs.base.yamlCamera import makeCamera 

49from lsst.resources import ResourcePath 

50from lsst.utils.introspection import get_full_type_name 

51from pydantic import BaseModel 

52 

53from .utils import createInitialSkyWcsFromBoresight 

54 

55if TYPE_CHECKING: 55 ↛ 56line 55 didn't jump to line 56, because the condition on line 55 was never true

56 from lsst.daf.butler import Butler 

57 

58DUMMY_FILTER_DEFINITIONS = FilterDefinitionCollection( 

59 FilterDefinition(physical_filter="dummy_u", band="u"), 

60 FilterDefinition(physical_filter="dummy_g", band="g"), 

61) 

62 

63 

64class DummyCamYamlWcsFormatter(YamlFormatter): 

65 """Specialist formatter for tests that can make a WCS.""" 

66 

67 @classmethod 

68 def makeRawSkyWcsFromBoresight(cls, boresight, orientation, detector): 

69 """Class method to make a raw sky WCS from boresight and detector. 

70 

71 This uses the API expected by define-visits. A working example 

72 can be found in `FitsRawFormatterBase`. 

73 

74 Notes 

75 ----- 

76 This makes no attempt to create a proper WCS from geometry. 

77 """ 

78 return createInitialSkyWcsFromBoresight(boresight, orientation, detector, flipX=False) 

79 

80 

81class CuratedCalibration(BaseModel): 

82 """Class that implements minimal read/write interface needed to support 

83 curated calibration ingest. 

84 """ 

85 

86 metadata: dict[str, Any] 

87 values: list[int] 

88 

89 @classmethod 

90 def readText(cls, path: str) -> CuratedCalibration: 

91 with open(path) as f: 

92 data = f.read() 

93 return cls.parse_obj(json.loads(data)) 

94 

95 def writeText(self, path: str) -> None: 

96 with open(path, "w") as f: 

97 print(self.json(), file=f) 

98 

99 def getMetadata(self) -> dict[str, Any]: 

100 return self.metadata 

101 

102 

103class DummyCam(Instrument): 

104 """Instrument class used for testing.""" 

105 

106 filterDefinitions = DUMMY_FILTER_DEFINITIONS 

107 additionalCuratedDatasetTypes = frozenset(["testCalib"]) 

108 policyName = "dummycam" 

109 dataPackageDir: str | None = "" 

110 raw_definition = ( 

111 "raw_dict", 

112 ("instrument", "detector", "exposure"), 

113 "StructuredDataDict", 

114 ) 

115 

116 @classmethod 

117 def getName(cls): 

118 return "DummyCam" 

119 

120 @classmethod 

121 @lru_cache # For mypy 

122 def getObsDataPackageDir(cls) -> str | None: 

123 return cls.dataPackageDir 

124 

125 def getCamera(self): 

126 # Return something that can be indexed by detector number 

127 # but also has to support getIdIter. 

128 with ResourcePath("resource://lsst.obs.base/test/dummycam.yaml").as_local() as local_file: 

129 return makeCamera(local_file.ospath) 

130 

131 def register(self, registry, update=False): 

132 """Insert Instrument, physical_filter, and detector entries into a 

133 `Registry`. 

134 """ 

135 detector_max = 2 

136 dataId = { 

137 "instrument": self.getName(), 

138 "class_name": get_full_type_name(DummyCam), 

139 "detector_max": detector_max, 

140 "visit_max": 1_000_000, 

141 "exposure_max": 1_000_000, 

142 } 

143 with registry.transaction(): 

144 registry.syncDimensionData("instrument", dataId, update=update) 

145 self._registerFilters(registry, update=update) 

146 for d in range(detector_max): 

147 registry.syncDimensionData( 

148 "detector", 

149 dict( 

150 dataId, 

151 id=d, 

152 full_name=f"RXX_S0{d}", 

153 ), 

154 update=update, 

155 ) 

156 

157 def getRawFormatter(self, dataId): 

158 # Docstring inherited fromt Instrument.getRawFormatter. 

159 return DummyCamYamlWcsFormatter 

160 

161 def applyConfigOverrides(self, name, config): 

162 pass 

163 

164 def writeAdditionalCuratedCalibrations( 

165 self, butler: Butler, collection: str | None = None, labels: Sequence[str] = () 

166 ) -> None: 

167 # We want to test the standard curated calibration ingest 

168 # but we do not have a standard class to use. There is no way 

169 # at the moment to inject a new class into the standard list 

170 # that is a package constant, so instead use this "Additional" 

171 # method but call the standard curated calibration code. 

172 if collection is None: 

173 collection = self.makeCalibrationCollectionName(*labels) 

174 butler.registry.registerCollection(collection, type=CollectionType.CALIBRATION) 

175 

176 datasetType = DatasetType( 

177 "testCalib", 

178 universe=butler.dimensions, 

179 isCalibration=True, 

180 dimensions=("instrument", "detector"), 

181 storageClass="CuratedCalibration", 

182 ) 

183 runs: set[str] = set() 

184 self._writeSpecificCuratedCalibrationDatasets( 

185 butler, datasetType, collection, runs=runs, labels=labels 

186 ) 

187 

188 

189@dataclasses.dataclass 

190class InstrumentTestData: 

191 """Values to test against in subclasses of `InstrumentTests`.""" 

192 

193 name: str 

194 """The name of the Camera this instrument describes.""" 

195 

196 nDetectors: int 

197 """The number of detectors in the Camera.""" 

198 

199 firstDetectorName: str 

200 """The name of the first detector in the Camera.""" 

201 

202 physical_filters: set[str] 

203 """A subset of the physical filters should be registered.""" 

204 

205 

206class InstrumentTests(metaclass=abc.ABCMeta): 

207 """Tests of sublcasses of Instrument. 

208 

209 TestCase subclasses must derive from this, then `TestCase`, and override 

210 ``data`` and ``instrument``. 

211 """ 

212 

213 data: ClassVar[InstrumentTestData | None] = None 

214 """`InstrumentTestData` containing the values to test against.""" 

215 

216 instrument: ClassVar[Instrument | None] = None 

217 """The `~lsst.obs.base.Instrument` to be tested.""" 

218 

219 def test_name(self): 

220 self.assertEqual(self.instrument.getName(), self.data.name) 

221 

222 def test_getCamera(self): 

223 """Test that getCamera() returns a reasonable Camera definition.""" 

224 camera = self.instrument.getCamera() 

225 self.assertEqual(camera.getName(), self.instrument.getName()) 

226 self.assertEqual(len(camera), self.data.nDetectors) 

227 self.assertEqual(next(iter(camera)).getName(), self.data.firstDetectorName) 

228 

229 def test_register(self): 

230 """Test that register() sets appropriate Dimensions.""" 

231 registryConfig = RegistryConfig() 

232 registryConfig["db"] = "sqlite://" 

233 registry = Registry.createFromConfig(registryConfig) 

234 # Check that the registry starts out empty. 

235 self.assertFalse(registry.queryDataIds(["instrument"]).toSequence()) 

236 self.assertFalse(registry.queryDataIds(["detector"]).toSequence()) 

237 self.assertFalse(registry.queryDataIds(["physical_filter"]).toSequence()) 

238 

239 # Register the instrument and check that certain dimensions appear. 

240 self.instrument.register(registry) 

241 instrumentDataIds = registry.queryDataIds(["instrument"]).toSequence() 

242 self.assertEqual(len(instrumentDataIds), 1) 

243 instrumentNames = {dataId["instrument"] for dataId in instrumentDataIds} 

244 self.assertEqual(instrumentNames, {self.data.name}) 

245 detectorDataIds = registry.queryDataIds(["detector"]).expanded().toSequence() 

246 self.assertEqual(len(detectorDataIds), self.data.nDetectors) 

247 detectorNames = {dataId.records["detector"].full_name for dataId in detectorDataIds} 

248 self.assertIn(self.data.firstDetectorName, detectorNames) 

249 physicalFilterDataIds = registry.queryDataIds(["physical_filter"]).toSequence() 

250 filterNames = {dataId["physical_filter"] for dataId in physicalFilterDataIds} 

251 self.assertGreaterEqual(filterNames, self.data.physical_filters) 

252 

253 # Check that the instrument class can be retrieved. 

254 registeredInstrument = Instrument.fromName(self.instrument.getName(), registry) 

255 self.assertEqual(type(registeredInstrument), type(self.instrument)) 

256 

257 # Check that re-registration is not an error. 

258 self.instrument.register(registry)