Coverage for tests/test_nightAnalysis.py: 98%
160 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-09 11:19 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-09 11:19 +0000
1#!/usr/bin/env python
2# This file is part of atmospec.
3#
4# Developed for the LSST Data Management System.
5# This product includes software developed by the LSST Project
6# (https://www.lsst.org).
7# See the COPYRIGHT file at the top-level directory of this distribution
8# for details of code ownership.
9#
10# This program is free software: you can redistribute it and/or modify
11# it under the terms of the GNU General Public License as published by
12# the Free Software Foundation, either version 3 of the License, or
13# (at your option) any later version.
14#
15# This program is distributed in the hope that it will be useful,
16# but WITHOUT ANY WARRANTY; without even the implied warranty of
17# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18# GNU General Public License for more details.
19#
20# You should have received a copy of the GNU General Public License
21# along with this program. If not, see <https://www.gnu.org/licenses/>.
22"""Test cases for atmospec.nightAnalysis."""
24import unittest
25from types import SimpleNamespace
26from unittest.mock import patch
28import numpy as np
29from astropy.table import Table
31import lsst.utils
32import lsst.utils.tests
34import lsst.atmospec.nightAnalysis as nightAnalysis
35from lsst.atmospec.nightAnalysis import (
36 NightStellarSpectra,
37 getLineValue,
38 _getRowNum,
39 LINE_NAMES,
40)
43def makeStrTable():
44 """Build a str-keyed table for the free-function tests.
46 Row 0 is deliberately a valid line so that the ``not rowNum``
47 branch (rowNum == 0 is falsy) can be exercised.
48 """
49 table = Table()
50 table["name"] = [LINE_NAMES["O2"], LINE_NAMES["H_alpha"], LINE_NAMES["H_beta"]]
51 table["value"] = [10.0, 20.0, 30.0]
52 return table
55def makeByteTable(lineNames):
56 """Build a byte-string keyed table like a real extraction file."""
57 table = Table()
58 encoded = np.array([LINE_NAMES[name].encode("utf-8") for name in lineNames])
59 table["name"] = encoded
60 table["value"] = np.arange(len(lineNames), dtype=float)
61 return table
64class FakeQuery:
65 """Iterable stand-in for a butler query result with order_by."""
67 def __init__(self, records):
68 self._records = records
70 def __iter__(self):
71 return iter(self._records)
73 def order_by(self, *args, **kwargs):
74 return self
77def makeRecords():
78 """Exposure records covering the seq_nums used in the tests."""
79 records = []
80 for seqNum, physFilter, mjd, za in (
81 (1, "filtA~disp1", 60000.1, 20.0),
82 (2, "filtB~disp2", 60000.2, 40.0),
83 (3, "filtA~disp1", 60000.3, 25.0),
84 (4, "filtB~disp2", 60000.4, 30.0),
85 ):
86 records.append(SimpleNamespace(
87 seq_num=seqNum,
88 physical_filter=physFilter,
89 zenith_angle=za,
90 timespan=SimpleNamespace(begin=SimpleNamespace(mjd=mjd)),
91 ))
92 return records
95def makeButlerClass(records, extractionMap):
96 """Create a fake Butler class.
98 ``extractionMap`` maps seq_num -> table, ``None`` (returns None) or
99 the string ``"lookup"`` (raises LookupError inside get).
100 """
102 class FakeButler:
103 def __init__(self, *args, **kwargs):
104 self.registry = SimpleNamespace(
105 queryDimensionRecords=lambda *a, **k: FakeQuery(records))
107 def get(self, datasetType, *, seq_num, day_obs):
108 result = extractionMap.get(seq_num, None)
109 if result == "lookup":
110 raise LookupError("no such dataset")
111 return result
113 return FakeButler
116def makeInstance(*, dayObs=20200101, target="HD12345", ignoreSeqNums=[],
117 records=None, extractionMap=None, dispersedSeqNums=(1, 2, 3),
118 passInstance=False):
119 """Construct a NightStellarSpectra with a fully mocked butler."""
120 if records is None: 120 ↛ 122line 120 didn't jump to line 122 because the condition on line 120 was always true
121 records = makeRecords()
122 if extractionMap is None:
123 extractionMap = {1: makeByteTable(["H_alpha", "H_beta"]),
124 2: makeByteTable(["H_alpha", "H_gamma"])}
126 FakeButler = makeButlerClass(records, extractionMap)
128 def fakeDispersed(dataId, butler):
129 return dataId["seq_num"] in dispersedSeqNums
131 butlerInput = FakeButler() if passInstance else object()
133 with patch.object(nightAnalysis.dafButler, "Butler", FakeButler), \
134 patch.object(nightAnalysis, "isDispersedDataId", side_effect=fakeDispersed):
135 instance = NightStellarSpectra(butlerInput, dayObs, target,
136 ignoreSeqNums=ignoreSeqNums)
137 return instance
140class NightAnalysisFunctionsTestCase(lsst.utils.tests.TestCase):
141 """Tests for the free functions _getRowNum and getLineValue."""
143 def test_getRowNum_found(self):
144 table = makeStrTable()
145 self.assertEqual(_getRowNum(table, "O2"), 0)
146 self.assertEqual(_getRowNum(table, "H_alpha"), 1)
147 self.assertEqual(_getRowNum(table, "H_beta"), 2)
149 def test_getRowNum_missingLine(self):
150 table = makeStrTable()
151 # Valid line name but not present in the table -> None.
152 self.assertIsNone(_getRowNum(table, "water"))
154 def test_getRowNum_unknownName(self):
155 table = makeStrTable()
156 with self.assertRaises(RuntimeError):
157 _getRowNum(table, "not_a_real_line")
159 def test_getLineValue_found(self):
160 table = makeStrTable()
161 # H_alpha is at row 1 (truthy) so the real value is returned.
162 self.assertEqual(getLineValue(table, "H_alpha", "value"), 20.0)
164 def test_getLineValue_rowZeroTreatedAsMissing(self):
165 table = makeStrTable()
166 # O2 is at row 0 which is falsy, so it is treated as missing.
167 self.assertTrue(np.isnan(getLineValue(table, "O2", "value")))
168 with self.assertRaises(ValueError):
169 getLineValue(table, "O2", "value", nanForMissingValues=False)
171 def test_getLineValue_missingNan(self):
172 table = makeStrTable()
173 self.assertTrue(np.isnan(getLineValue(table, "water", "value")))
175 def test_getLineValue_missingRaises(self):
176 table = makeStrTable()
177 with self.assertRaises(ValueError):
178 getLineValue(table, "water", "value", nanForMissingValues=False)
181class NightStellarSpectraTestCase(lsst.utils.tests.TestCase):
182 """Tests for the NightStellarSpectra class."""
184 def test_construction_elseBranch(self):
185 night = makeInstance(ignoreSeqNums=[3])
186 # 1, 2 dispersed and kept; 3 dispersed but ignored; 4 not dispersed.
187 self.assertEqual(night.seqNums, [1, 2])
188 self.assertEqual(set(night.data.keys()), {1, 2})
189 self.assertEqual(night.dayObs, 20200101)
190 self.assertEqual(night.targetName, "HD12345")
192 def test_construction_isinstanceBranch(self):
193 # Passing an actual FakeButler instance exercises the isinstance
194 # True branch of __init__.
195 night = makeInstance(passInstance=True)
196 self.assertEqual(night.seqNums, [1, 2])
198 def test_construction_missingExtraction(self):
199 # seq_num 2 raises LookupError inside get -> not loaded.
200 extractionMap = {1: makeByteTable(["H_alpha"]), 2: "lookup"}
201 night = makeInstance(extractionMap=extractionMap)
202 self.assertEqual(night.seqNums, [1])
203 self.assertEqual(set(night.data.keys()), {1})
205 def test_construction_noneExtraction(self):
206 # get returns None -> falsy -> not loaded.
207 extractionMap = {1: makeByteTable(["H_alpha"]), 2: None}
208 night = makeInstance(extractionMap=extractionMap)
209 self.assertEqual(night.seqNums, [1])
211 def test_isDispersed(self):
212 night = makeInstance()
213 with patch.object(nightAnalysis, "isDispersedDataId",
214 side_effect=lambda dataId, butler: dataId["seq_num"] == 5):
215 self.assertTrue(night.isDispersed(5))
216 self.assertFalse(night.isDispersed(6))
218 def test_readOneExtractionFile_lookupError(self):
219 night = makeInstance()
220 with patch.object(night.butler, "get", side_effect=LookupError):
221 self.assertIsNone(night._readOneExtractionFile(999))
223 def test_removeSeqNums(self):
224 night = makeInstance(ignoreSeqNums=[3])
225 night.removeSeqNums([2, 42]) # 42 not present -> no-op
226 self.assertEqual(night.seqNums, [1])
227 self.assertNotIn(2, night.data)
229 def test_getFilterDisperserSet(self):
230 night = makeInstance(ignoreSeqNums=[3])
231 self.assertEqual(night.getFilterDisperserSet(),
232 {"filtA~disp1", "filtB~disp2"})
234 def test_getFilterSet(self):
235 night = makeInstance(ignoreSeqNums=[3])
236 self.assertEqual(night.getFilterSet(), {"filtA", "filtB"})
238 def test_getDisperserSet(self):
239 night = makeInstance(ignoreSeqNums=[3])
240 self.assertEqual(night.getDisperserSet(), {"disp1", "disp2"})
242 def test_getLineValue_instanceMethod(self):
243 night = makeInstance(ignoreSeqNums=[3])
244 # Byte-string tables never match the str line names, so this is nan.
245 self.assertTrue(np.isnan(night.getLineValue(1, "H_alpha", "value")))
247 def test_getLineValues(self):
248 night = makeInstance(ignoreSeqNums=[3])
249 # Add a seq_num that is not in data to exercise the else branch.
250 night.seqNums = [1, 2, 99]
251 values = night.getLineValues("H_alpha", "value")
252 self.assertEqual(len(values), 3)
253 self.assertTrue(all(np.isnan(v) for v in values))
255 def test_getAllTableLines_intermittent(self):
256 night = makeInstance(ignoreSeqNums=[3])
257 lines = night.getAllTableLines(includeIntermittentLines=True)
258 self.assertEqual(set(lines), {"H_alpha", "H_beta", "H_gamma"})
260 def test_getAllTableLines_common(self):
261 night = makeInstance(ignoreSeqNums=[3])
262 lines = night.getAllTableLines(includeIntermittentLines=False)
263 self.assertEqual(set(lines), {"H_alpha"})
265 def test_getObsTimes(self):
266 night = makeInstance(ignoreSeqNums=[3])
267 self.assertEqual(night.getObsTimes(), [60000.1, 60000.2])
269 def test_getAirmasses(self):
270 night = makeInstance(ignoreSeqNums=[3])
271 airmasses = night.getAirmasses()
272 self.assertEqual(len(airmasses), 2)
273 expected = 1.0 / np.cos(np.deg2rad(20.0))
274 self.assertFloatsAlmostEqual(airmasses[0], expected)
276 def test_printObservationTable(self):
277 night = makeInstance(ignoreSeqNums=[3])
278 # Simply exercise the code path.
279 night.printObservationTable()
282class TestMemory(lsst.utils.tests.MemoryTestCase):
283 pass
286def setup_module(module):
287 lsst.utils.tests.init()
290if __name__ == "__main__": 290 ↛ 291line 290 didn't jump to line 291 because the condition on line 290 was never true
291 lsst.utils.tests.init()
292 unittest.main()