Coverage for tests / test_instrument.py: 23%

158 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-01 08:20 +0000

1# This file is part of pipe_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 software is dual licensed under the GNU General Public License and also 

10# under a 3-clause BSD license. Recipients may choose which of these licenses 

11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt, 

12# respectively. If you choose the GPL option then the following text applies 

13# (but note that there is still no warranty even if you opt for BSD instead): 

14# 

15# This program is free software: you can redistribute it and/or modify 

16# it under the terms of the GNU General Public License as published by 

17# the Free Software Foundation, either version 3 of the License, or 

18# (at your option) any later version. 

19# 

20# This program is distributed in the hope that it will be useful, 

21# but WITHOUT ANY WARRANTY; without even the implied warranty of 

22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

23# GNU General Public License for more details. 

24# 

25# You should have received a copy of the GNU General Public License 

26# along with this program. If not, see <http://www.gnu.org/licenses/>. 

27 

28"""Tests of the Instrument class.""" 

29 

30import datetime 

31import math 

32import unittest 

33 

34from lsst.daf.butler import DataCoordinate, DimensionPacker, DimensionUniverse 

35from lsst.daf.butler.formatters.json import JsonFormatter 

36from lsst.daf.butler.tests.utils import create_populated_sqlite_registry 

37from lsst.pex.config import Config 

38from lsst.pipe.base import Instrument 

39from lsst.utils.introspection import get_full_type_name 

40 

41 

42class BaseDummyInstrument(Instrument): 

43 """Test instrument base class.""" 

44 

45 @classmethod 

46 def getName(cls): 

47 return "DummyInstrument" 

48 

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

50 detector_max = 2 

51 visit_max = 10 

52 exposure_max = 8 

53 record = { 

54 "instrument": self.getName(), 

55 "class_name": get_full_type_name(DummyInstrument), 

56 "detector_max": detector_max, 

57 "visit_max": visit_max, 

58 "exposure_max": exposure_max, 

59 } 

60 with registry.transaction(): 

61 registry.syncDimensionData("instrument", record, update=update) 

62 

63 def getRawFormatter(self, dataId): 

64 return JsonFormatter 

65 

66 

67class DummyInstrument(BaseDummyInstrument): 

68 """Test instrument.""" 

69 

70 

71class NotInstrument: 

72 """Not an instrument class at all.""" 

73 

74 def __init__(self, collection_prefix: str = ""): 

75 self.collection_prefix = collection_prefix 

76 

77 

78class BadInstrument(DummyInstrument): 

79 """Instrument with wrong class name.""" 

80 

81 raw_definition = ("raw2", ("instrument", "detector", "exposure"), "StructuredDataDict") 

82 

83 @classmethod 

84 def getName(cls): 

85 return "BadInstrument" 

86 

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

88 # Register a bad class name 

89 record = { 

90 "instrument": self.getName(), 

91 "class_name": "builtins.str", 

92 "detector_max": 1, 

93 } 

94 registry.syncDimensionData("instrument", record, update=update) 

95 

96 

97class UnimportableInstrument(DummyInstrument): 

98 """Instrument with class name that does not exist.""" 

99 

100 @classmethod 

101 def getName(cls): 

102 return "NoImportInstr" 

103 

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

105 # Register a bad class name 

106 record = { 

107 "instrument": self.getName(), 

108 "class_name": "not.importable", 

109 "detector_max": 1, 

110 } 

111 registry.syncDimensionData("instrument", record, update=update) 

112 

113 

114class DimensionPackerTestConfig(Config): 

115 """Configuration for the dimension packer.""" 

116 

117 packer = Instrument.make_dimension_packer_config_field() 

118 

119 

120class InstrumentTestCase(unittest.TestCase): 

121 """Test for Instrument.""" 

122 

123 def setUp(self): 

124 self.instrument = DummyInstrument() 

125 self.name = "DummyInstrument" 

126 

127 def test_basics(self): 

128 self.assertEqual(self.instrument.getName(), self.name) 

129 self.assertEqual(self.instrument.getRawFormatter({}), JsonFormatter) 

130 self.assertIsNone(DummyInstrument.raw_definition) 

131 raw = BadInstrument.raw_definition 

132 self.assertEqual(raw[2], "StructuredDataDict") 

133 

134 def test_register(self): 

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

136 butler = create_populated_sqlite_registry() 

137 self.enterContext(butler) 

138 registry = butler.registry 

139 # Check that the registry starts out empty. 

140 self.instrument.importAll(registry) 

141 self.assertFalse(list(registry.queryDimensionRecords("instrument"))) 

142 

143 # Register and check again. 

144 self.instrument.register(registry) 

145 instruments = list(registry.queryDimensionRecords("instrument")) 

146 self.assertEqual(len(instruments), 1) 

147 self.assertEqual(instruments[0].name, self.name) 

148 self.assertEqual(instruments[0].detector_max, 2) 

149 self.assertIn("DummyInstrument", instruments[0].class_name) 

150 

151 self.instrument.importAll(registry) 

152 from_registry = DummyInstrument.fromName("DummyInstrument", registry) 

153 self.assertIsInstance(from_registry, Instrument) 

154 with self.assertRaises(LookupError): 

155 Instrument.fromName("NotThrere", registry) 

156 

157 # Register a bad instrument. 

158 BadInstrument().register(registry) 

159 with self.assertRaises(TypeError): 

160 Instrument.fromName("BadInstrument", registry) 

161 

162 UnimportableInstrument().register(registry) 

163 with self.assertRaises(ImportError): 

164 Instrument.fromName("NoImportInstr", registry) 

165 

166 # This should work even with the bad class name. 

167 self.instrument.importAll(registry) 

168 

169 # Check that from_string falls back to fromName. 

170 from_string = DummyInstrument.from_string("DummyInstrument", registry) 

171 self.assertIsInstance(from_string, type(from_registry)) 

172 

173 def test_from_string(self): 

174 """Test Instrument.from_string works.""" 

175 with self.assertRaises(RuntimeError): 

176 # No registry. 

177 DummyInstrument.from_string("DummyInstrument") 

178 

179 with self.assertRaises(TypeError): 

180 # This will raise because collection_prefix is not understood. 

181 DummyInstrument.from_string("lsst.pipe.base.Task") 

182 

183 with self.assertRaises(TypeError): 

184 # Not an instrument but does have collection_prefix. 

185 DummyInstrument.from_string(get_full_type_name(NotInstrument)) 

186 

187 with self.assertRaises(TypeError): 

188 # This will raise because BaseDummyInstrument is not a subclass 

189 # of DummyInstrument. 

190 DummyInstrument.from_string(get_full_type_name(BaseDummyInstrument)) 

191 

192 instrument = DummyInstrument.from_string( 

193 get_full_type_name(DummyInstrument), collection_prefix="test" 

194 ) 

195 self.assertEqual(instrument.collection_prefix, "test") 

196 

197 def test_defaults(self): 

198 self.assertEqual(self.instrument.makeDefaultRawIngestRunName(), "DummyInstrument/raw/all") 

199 self.assertEqual( 

200 self.instrument.makeUnboundedCalibrationRunName("a", "b"), "DummyInstrument/calib/a/b/unbounded" 

201 ) 

202 self.assertEqual( 

203 self.instrument.makeCuratedCalibrationRunName("2018-05-04", "a"), 

204 "DummyInstrument/calib/a/curated/20180504T000000Z", 

205 ) 

206 self.assertEqual(self.instrument.makeCalibrationCollectionName("c"), "DummyInstrument/calib/c") 

207 self.assertEqual(self.instrument.makeRefCatCollectionName(), "refcats") 

208 self.assertEqual(self.instrument.makeRefCatCollectionName("a"), "refcats/a") 

209 self.assertEqual(self.instrument.makeUmbrellaCollectionName(), "DummyInstrument/defaults") 

210 

211 instrument = DummyInstrument(collection_prefix="Different") 

212 self.assertEqual(instrument.makeCollectionName("a"), "Different/a") 

213 self.assertEqual(self.instrument.makeCollectionName("a"), "DummyInstrument/a") 

214 

215 def test_collection_timestamps(self): 

216 self.assertEqual( 

217 Instrument.formatCollectionTimestamp("2018-05-03"), 

218 "20180503T000000Z", 

219 ) 

220 self.assertEqual( 

221 Instrument.formatCollectionTimestamp("2018-05-03T14:32:16"), 

222 "20180503T143216Z", 

223 ) 

224 self.assertEqual( 

225 Instrument.formatCollectionTimestamp("20180503T143216Z"), 

226 "20180503T143216Z", 

227 ) 

228 self.assertEqual( 

229 Instrument.formatCollectionTimestamp(datetime.datetime(2018, 5, 3, 14, 32, 16)), 

230 "20180503T143216Z", 

231 ) 

232 formattedNow = Instrument.makeCollectionTimestamp() 

233 self.assertIsInstance(formattedNow, str) 

234 datetimeThen1 = datetime.datetime.strptime(formattedNow, "%Y%m%dT%H%M%S%z") 

235 self.assertEqual(datetimeThen1.tzinfo, datetime.UTC) 

236 

237 with self.assertRaises(TypeError): 

238 Instrument.formatCollectionTimestamp(0) 

239 

240 def test_dimension_packer_config_defaults(self): 

241 """Test the ObservationDimensionPacker class and the Instrument-based 

242 systems for constructing it, in the case where the Instrument-defined 

243 default is used. 

244 """ 

245 butler = create_populated_sqlite_registry() 

246 self.enterContext(butler) 

247 registry = butler.registry 

248 self.instrument.register(registry) 

249 config = DimensionPackerTestConfig() 

250 instrument_data_id = registry.expandDataId(instrument=self.name) 

251 record = instrument_data_id.records["instrument"] 

252 self.check_dimension_packers( 

253 registry.dimensions, 

254 # Test just one packer-construction signature here as that saves us 

255 # from having to insert dimension records for visits, exposures, 

256 # and detectors for this test. 

257 # Note that we don't need to pass any more than the instrument in 

258 # the data ID yet, because we're just constructing packers, not 

259 # calling their pack method. 

260 visit_packers=[ 

261 config.packer.apply(instrument_data_id, is_exposure=False), 

262 Instrument.make_default_dimension_packer(instrument_data_id, is_exposure=False), 

263 ], 

264 exposure_packers=[ 

265 config.packer.apply(instrument_data_id, is_exposure=True), 

266 Instrument.make_default_dimension_packer(instrument_data_id, is_exposure=True), 

267 ], 

268 n_detectors=record.detector_max, 

269 n_visits=record.visit_max, 

270 n_exposures=record.exposure_max, 

271 ) 

272 

273 def test_dimension_packer_config_override(self): 

274 """Test the ObservationDimensionPacker class and the Instrument-based 

275 systems for constructing it, in the case where configuration overrides 

276 the Instrument's default. 

277 """ 

278 butler = create_populated_sqlite_registry() 

279 self.enterContext(butler) 

280 registry = butler.registry 

281 # Intentionally do not register instrument or insert any other 

282 # dimension records to ensure we don't need them in this mode. 

283 config = DimensionPackerTestConfig() 

284 config.packer["observation"].n_observations = 16 

285 config.packer["observation"].n_detectors = 4 

286 config.packer.name = "observation" 

287 instrument_data_id = DataCoordinate.standardize(instrument=self.name, universe=registry.dimensions) 

288 full_data_id = DataCoordinate.standardize(instrument_data_id, detector=1, visit=3, exposure=7) 

289 visit_data_id = full_data_id.subset(full_data_id.universe.conform(["visit", "detector"])) 

290 exposure_data_id = full_data_id.subset(full_data_id.universe.conform(["exposure", "detector"])) 

291 self.check_dimension_packers( 

292 registry.dimensions, 

293 # Note that we don't need to pass any more than the instrument in 

294 # the data ID yet, because we're just constructing packers, not 

295 # calling their pack method. 

296 visit_packers=[ 

297 config.packer.apply(visit_data_id), 

298 config.packer.apply(full_data_id), 

299 config.packer.apply(instrument_data_id, is_exposure=False), 

300 ], 

301 exposure_packers=[ 

302 config.packer.apply(instrument_data_id, is_exposure=True), 

303 config.packer.apply(exposure_data_id), 

304 ], 

305 n_detectors=config.packer["observation"].n_detectors, 

306 n_visits=config.packer["observation"].n_observations, 

307 n_exposures=config.packer["observation"].n_observations, 

308 ) 

309 

310 def check_dimension_packers( 

311 self, 

312 universe: DimensionUniverse, 

313 visit_packers: list[DimensionPacker], 

314 exposure_packers: list[DimensionPacker], 

315 n_detectors: int, 

316 n_visits: int, 

317 n_exposures: int, 

318 ) -> None: 

319 """Test the behavior of one or more dimension packers constructed by 

320 an instrument. 

321 

322 Parameters 

323 ---------- 

324 universe : `lsst.daf.butler.DimensionUniverse` 

325 Data model for butler data IDs. 

326 visit_packers : `list` [ `DimensionPacker` ] 

327 Packers with ``{visit, detector}`` dimensions to test. 

328 exposure_packers : `list` [ `DimensionPacker` ] 

329 Packers with ``{exposure, detector}`` dimensions to test. 

330 n_detectors : `int` 

331 Number of detectors all packers have been configured to reserve 

332 space for. 

333 n_visits : `int` 

334 Number of visits all packers in ``visit_packers`` have been 

335 configured to reserve space for. 

336 n_exposures : `int` 

337 Number of exposures all packers in ``exposure_packers`` have been 

338 configured to reserve space for. 

339 """ 

340 full_data_id = DataCoordinate.standardize( 

341 instrument=self.name, detector=1, visit=3, exposure=7, universe=universe 

342 ) 

343 visit_data_id = full_data_id.subset(full_data_id.universe.conform(["visit", "detector"])) 

344 exposure_data_id = full_data_id.subset(full_data_id.universe.conform(["exposure", "detector"])) 

345 for n, packer in enumerate(visit_packers): 

346 with self.subTest(n=n): 

347 packed_id, max_bits = packer.pack(full_data_id, returnMaxBits=True) 

348 self.assertEqual(packed_id, full_data_id["detector"] + full_data_id["visit"] * n_detectors) 

349 self.assertEqual(max_bits, math.ceil(math.log2(n_detectors * n_visits))) 

350 self.assertEqual(visit_data_id, packer.unpack(packed_id)) 

351 with self.assertRaisesRegex(ValueError, f"Detector ID {n_detectors} is out of bounds"): 

352 packer.pack(instrument=self.name, detector=n_detectors, visit=3) 

353 with self.assertRaisesRegex(ValueError, f"Visit ID {n_visits} is out of bounds"): 

354 packer.pack(instrument=self.name, detector=1, visit=n_visits) 

355 for n, packer in enumerate(exposure_packers): 

356 with self.subTest(n=n): 

357 packed_id, max_bits = packer.pack(full_data_id, returnMaxBits=True) 

358 self.assertEqual(packed_id, full_data_id["detector"] + full_data_id["exposure"] * n_detectors) 

359 self.assertEqual(max_bits, math.ceil(math.log2(n_detectors * n_exposures))) 

360 self.assertEqual(exposure_data_id, packer.unpack(packed_id)) 

361 with self.assertRaisesRegex(ValueError, f"Detector ID {n_detectors} is out of bounds"): 

362 packer.pack(instrument=self.name, detector=n_detectors, exposure=3) 

363 with self.assertRaisesRegex(ValueError, f"Exposure ID {n_exposures} is out of bounds"): 

364 packer.pack(instrument=self.name, detector=1, exposure=n_exposures) 

365 

366 

367if __name__ == "__main__": 

368 unittest.main()