Coverage for tests / test_defineVisits.py: 23%
185 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-02 01:18 -0800
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-02 01:18 -0800
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/>.
22import os
23import pickle
24import shutil
25import tempfile
26import unittest
27import warnings
28from collections import defaultdict
30import lsst.daf.butler.tests as butlerTests
31from lsst.daf.butler import DataCoordinate, DimensionRecord, SerializedDimensionRecord
32from lsst.daf.butler.registry import ConflictingDefinitionError
33from lsst.obs.base import DefineVisitsConfig, DefineVisitsTask
34from lsst.obs.base.instrument_tests import DummyCam
35from lsst.utils.iteration import ensure_iterable
37TESTDIR = os.path.dirname(__file__)
38DATADIR = os.path.join(TESTDIR, "data", "visits")
41class DefineVisitsBase:
42 """General set up that can be shared."""
44 use_data_ids = True
45 """Use data IDs when calling defineVisits, else use dimension records."""
47 def setUpExposures(self):
48 """Create a new butler for each test since we are changing dimension
49 records.
50 """
51 self.root = tempfile.mkdtemp(dir=TESTDIR)
52 self.creatorButler = butlerTests.makeTestRepo(self.root, {})
53 self.enterContext(self.creatorButler)
54 self.butler = butlerTests.makeTestCollection(self.creatorButler, uniqueId=self.id())
55 self.enterContext(self.butler)
57 self.config = self.get_config()
58 self.task = DefineVisitsTask(config=self.config, butler=self.butler)
60 # Need to register the instrument.
61 DummyCam().register(self.butler.registry)
63 # Choose serializations based on universe.
64 universe = self.butler.dimensions
65 uversion = universe.version
66 # Not all universe changes result in visible changes.
67 match uversion:
68 case uversion if uversion < 2:
69 raise unittest.SkipTest(f"Universe {uversion} is not compatible with these test files.")
70 case 2 | 3 | 4 | 5:
71 # has_simulated, azimuth, seq_start, seq_end.
72 v = 2
73 case 6:
74 # group not group_name, group_id dropped.
75 v = 6
76 case 7:
77 # can_see_sky.
78 v = 7
79 case _:
80 # Might work.
81 warnings.warn(f"Universe {uversion} has not been validated.")
82 v = 7
84 # Read the exposure records.
85 self.records: dict[int, DimensionRecord] = {}
86 for i in (347, 348, 349):
87 with open(os.path.join(DATADIR, f"exp_v{v}_{i}.json")) as fh:
88 simple = SerializedDimensionRecord.model_validate_json(fh.read())
89 self.records[i] = DimensionRecord.from_simple(simple, registry=self.butler.registry)
91 def define_visits(
92 self,
93 exposures: list[DimensionRecord | list[DimensionRecord]],
94 incremental: bool,
95 ) -> None:
96 for records in exposures:
97 records = list(ensure_iterable(records))
98 if "group" in self.butler.dimensions["exposure"].implied:
99 # This is a group + day_obs universe.
100 for rec in records:
101 self.butler.registry.syncDimensionData(
102 "group", dict(instrument=rec.instrument, name=rec.group)
103 )
104 self.butler.registry.syncDimensionData(
105 "day_obs", dict(instrument=rec.instrument, id=rec.day_obs)
106 )
108 self.butler.registry.insertDimensionData("exposure", *records)
109 # Include all records so far in definition.
110 if self.use_data_ids:
111 dataIds = list(self.butler.registry.queryDataIds("exposure", instrument="DummyCam"))
112 else:
113 dataIds = records
114 self.task.run(dataIds, incremental=incremental)
117class DefineVisitsTestCase(unittest.TestCase, DefineVisitsBase):
118 """Test visit definition."""
120 def setUp(self):
121 self.setUpExposures()
123 def tearDown(self):
124 if self.root is not None:
125 shutil.rmtree(self.root, ignore_errors=True)
127 def get_config(self) -> DefineVisitsConfig:
128 return DefineVisitsTask.ConfigClass()
130 def assertVisits(self):
131 """Check that the visits were registered as expected."""
132 visits = list(self.butler.registry.queryDimensionRecords("visit"))
133 self.assertEqual(len(visits), 4)
134 self.assertEqual(
135 {visit.id for visit in visits}, {2022040500347, 2022040500348, 2022040500349, 92022040500348}
136 )
138 # Ensure that the definitions are correct (ignoring order).
139 defmap = defaultdict(set)
140 definitions = list(self.butler.registry.queryDimensionRecords("visit_definition"))
141 for defn in definitions:
142 defmap[defn.visit].add(defn.exposure)
144 self.assertEqual(
145 dict(defmap),
146 {
147 92022040500348: {2022040500348},
148 2022040500347: {2022040500347},
149 2022040500348: {2022040500348, 2022040500349},
150 2022040500349: {2022040500349},
151 },
152 )
154 def test_defineVisits(self):
155 # Test visit definition with all the records.
156 self.define_visits([list(self.records.values())], incremental=False) # list inside a list
157 self.assertVisits()
159 def test_incremental_cumulative(self):
160 # Define the visits after each exposure.
161 self.define_visits(list(self.records.values()), incremental=True)
162 self.assertVisits()
164 def test_incremental_cumulative_reverse(self):
165 # In reverse order we should still eventually end up with the right
166 # answer.
167 with self.assertLogs("lsst.defineVisits.groupExposures", level="WARNING") as cm:
168 self.define_visits(list(reversed(self.records.values())), incremental=True)
169 self.assertIn("Skipping the multi-snap definition", "\n".join(cm.output))
170 self.assertVisits()
172 def define_visits_incrementally(self, exposure: DimensionRecord) -> None:
173 if "group" in self.butler.dimensions["exposure"].implied:
174 self.butler.registry.syncDimensionData(
175 "group", dict(instrument=exposure.instrument, name=exposure.group)
176 )
177 self.butler.registry.syncDimensionData(
178 "day_obs",
179 dict(
180 instrument=exposure.instrument,
181 id=exposure.day_obs,
182 ),
183 )
184 self.butler.registry.insertDimensionData("exposure", exposure)
185 dataIds = [
186 DataCoordinate.standardize(
187 instrument="DummyCam", exposure=exposure.id, universe=self.butler.dimensions
188 )
189 ]
190 self.task.run(dataIds, incremental=True)
192 def test_incremental(self):
193 for record in self.records.values():
194 self.define_visits_incrementally(record)
195 self.assertVisits()
197 def test_incremental_reverse(self):
198 for record in reversed(self.records.values()):
199 self.define_visits_incrementally(record)
200 self.assertVisits()
202 def testPickleTask(self):
203 stream = pickle.dumps(self.task)
204 copy = pickle.loads(stream)
205 self.enterContext(copy.butler)
206 self.assertEqual(self.task.getFullName(), copy.getFullName())
207 self.assertEqual(self.task.log.name, copy.log.name)
208 self.assertEqual(self.task.config, copy.config)
209 self.assertEqual(self.task.butler._config, copy.butler._config)
210 self.assertEqual(list(self.task.butler.collections.defaults), list(copy.butler.collections.defaults))
211 self.assertEqual(self.task.butler.run, copy.butler.run)
212 self.assertEqual(self.task.universe, copy.universe)
215class DefineVisitsRecordsTestCase(DefineVisitsTestCase):
216 """Define visits using only dimension records."""
218 use_data_ids = False
221class DefineVisitsGroupingTestCase(unittest.TestCase, DefineVisitsBase):
222 """Test visit grouping by group metadata."""
224 def setUp(self):
225 self.setUpExposures()
227 def tearDown(self):
228 if self.root is not None:
229 shutil.rmtree(self.root, ignore_errors=True)
231 def get_config(self) -> DefineVisitsConfig:
232 config = DefineVisitsTask.ConfigClass()
233 config.groupExposures.name = "by-group-metadata"
234 return config
236 def test_defineVisits(self):
237 # Test visit definition with all the records.
238 self.define_visits([list(self.records.values())], incremental=False) # list inside a list
239 self.assertVisits()
241 def assertVisits(self):
242 """Check that the visits were registered as expected."""
243 visits = list(self.butler.registry.queryDimensionRecords("visit"))
244 self.assertEqual(len(visits), 2)
246 # The visit ID itself depends on which universe we are using.
247 # It is either calculated or comes from the JSON record.
248 if "group" in self.butler.dimensions["exposure"].implied:
249 visit_ids = [20220406025653255, 20220406025807181]
250 else:
251 visit_ids = [2291434132550000, 2291434871810000]
252 self.assertEqual({visit.id for visit in visits}, set(visit_ids))
254 # Ensure that the definitions are correct (ignoring order).
255 defmap = defaultdict(set)
256 definitions = list(self.butler.registry.queryDimensionRecords("visit_definition"))
257 for defn in definitions:
258 defmap[defn.visit].add(defn.exposure)
260 self.assertEqual(
261 dict(defmap),
262 {
263 visit_ids[0]: {2022040500347},
264 visit_ids[1]: {2022040500348, 2022040500349},
265 },
266 )
269class DefineVisitsGroupingRecordsTestCase(DefineVisitsGroupingTestCase):
270 """Test using dimension records instead of Data IDs."""
272 use_data_ids = False
275class DefineVisitsOneToOneTestCase(unittest.TestCase, DefineVisitsBase):
276 """Test visit grouping by group metadata."""
278 def setUp(self):
279 self.setUpExposures()
281 def tearDown(self):
282 if self.root is not None:
283 shutil.rmtree(self.root, ignore_errors=True)
285 def get_config(self) -> DefineVisitsConfig:
286 config = DefineVisitsTask.ConfigClass()
287 config.groupExposures.name = "one-to-one"
288 return config
290 def test_defineVisits(self):
291 # Test visit definition with all the records.
292 self.define_visits([list(self.records.values())], incremental=False) # list inside a list
293 self.assertVisits()
295 def assertVisits(self):
296 """Check that the visits were registered as expected."""
297 visits = list(self.butler.registry.queryDimensionRecords("visit"))
298 self.assertEqual(len(visits), 3)
300 # For one-to-one the visit ID is the exposure ID.
301 visit_ids = [rec.id for rec in self.records.values()]
302 self.assertEqual({visit.id for visit in visits}, set(visit_ids))
304 # Ensure that the definitions are correct (ignoring order).
305 defmap = defaultdict(set)
306 definitions = list(self.butler.registry.queryDimensionRecords("visit_definition"))
307 for defn in definitions:
308 defmap[defn.visit].add(defn.exposure)
310 self.assertEqual(
311 dict(defmap),
312 {
313 visit_ids[0]: {2022040500347},
314 visit_ids[1]: {2022040500348},
315 visit_ids[2]: {2022040500349},
316 },
317 )
319 def test_update_records(self):
320 self.define_visits([list(self.records.values())], incremental=False) # list inside a list
321 self.assertVisits()
323 # Modify one of the records.
324 records = self.records
325 simple = records[348].to_simple()
326 simple.record["target_name"] = "new target"
327 records[348] = DimensionRecord.from_simple(simple, universe=self.butler.dimensions)
328 self.butler.registry.syncDimensionData("exposure", records[348], update=True)
330 # Re-run without updates or skipping should fail.
331 with self.assertRaises(ConflictingDefinitionError):
332 self.task.run(records.values())
334 result = self.task.run(records.values(), skip_conflicting=True)
335 self.assertEqual(result.n_skipped, 3, str(result))
337 # Check that the visit definition did not change.
338 visit_348 = self.butler.query_dimension_records("visit", where="visit.seq_num = 348")[0]
339 self.assertEqual(visit_348.target_name, "LATISS_E6A_00000040", visit_348)
341 # Run with forced update.
342 result = self.task.run(records.values(), skip_conflicting=True, update_records=True)
344 # Every record reports it was updated if we are updating, even if
345 # a record was not really changed.
346 self.assertEqual(result.n_skipped, 0, str(result))
347 self.assertEqual(result.n_fully_updated, 3, str(result))
348 visit_348 = self.butler.query_dimension_records("visit", where="visit.seq_num = 348")[0]
349 self.assertEqual(visit_348.target_name, "new target", visit_348)
352if __name__ == "__main__": 352 ↛ 353line 352 didn't jump to line 353 because the condition on line 352 was never true
353 unittest.main()