Skip to content

Commit 53de1ae

Browse files
authored
add option to fits reader to open L1 products also in UTC >L1 UTC per default (via keyword in header) (#447)
* change internal time system of L1 products to be SCETime or UTC * fixes after review * add warning when time out converting with spice * rework to not auto convert times in dta table at processing time only on fits open * cleanup after review * pytz.UTC
1 parent 01a7086 commit 53de1ae

9 files changed

Lines changed: 132 additions & 37 deletions

File tree

stixcore/io/product_processors/fits/processors.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -723,22 +723,23 @@ def generate_primary_header(cls, filename, product, *, version=0):
723723
# if not isinstance(product.obt_beg, SCETime):
724724
# raise ValueError("Expected SCETime as time format")
725725

726+
scet_timerange = product.scet_timerange
726727
headers = FitsProcessor.generate_common_header(filename, product, version=version) + (
727728
# Name, Value, Comment
728729
# ('MJDREF', product.obs_beg.mjd),
729730
# ('DATEREF', product.obs_beg.fits),
730-
("OBT_BEG", product.scet_timerange.start.as_float().value, "Start acquisition time in OBT"),
731-
("OBT_END", product.scet_timerange.end.as_float().value, "End acquisition time in OBT"),
731+
("OBT_BEG", scet_timerange.start.as_float().value, "Start acquisition time in OBT"),
732+
("OBT_END", scet_timerange.end.as_float().value, "End acquisition time in OBT"),
732733
("TIMESYS", "OBT", "System used for time keywords"),
733734
("LEVEL", "L0", "Processing level of the data"),
734-
("DATE-OBS", product.scet_timerange.start.to_string(), "Depreciated, same as DATE-BEG"),
735-
("DATE-BEG", product.scet_timerange.start.to_string(), "Start time of observation"),
736-
("DATE-AVG", product.scet_timerange.avg.to_string(), "Average time of observation"),
737-
("DATE-END", product.scet_timerange.end.to_string(), "End time of observation"),
735+
("DATE-OBS", scet_timerange.start.to_string(), "Depreciated, same as DATE-BEG"),
736+
("DATE-BEG", scet_timerange.start.to_string(), "Start time of observation"),
737+
("DATE-AVG", scet_timerange.avg.to_string(), "Average time of observation"),
738+
("DATE-END", scet_timerange.end.to_string(), "End time of observation"),
738739
("DATAMIN", product.dmin, "Minimum valid physical value"),
739740
("DATAMAX", product.dmax, "Maximum valid physical value"),
740741
("BUNIT", product.bunit, "Units of physical value, after application of BSCALE, BZERO"),
741-
("XPOSURE", product.exposure, "[s] shortest exposure time"),
742+
("XPOSURE", product.min_exposure, "[s] shortest exposure time"),
742743
("XPOMAX", product.max_exposure, "[s] maximum exposure time"),
743744
)
744745

@@ -782,7 +783,7 @@ def generate_primary_header(self, filename, product, *, version=0):
782783
("DATAMIN", empty_if_nan(product.dmin), "Minimum valid physical value"),
783784
("DATAMAX", empty_if_nan(product.dmax), "Maximum valid physical value"),
784785
("BUNIT", product.bunit, "Units of physical value, after application of BSCALE, BZERO"),
785-
("XPOSURE", empty_if_nan(product.exposure), "[s] shortest exposure time"),
786+
("XPOSURE", empty_if_nan(product.min_exposure), "[s] shortest exposure time"),
786787
("XPOMAX", empty_if_nan(product.max_exposure), "[s] maximum exposure time"),
787788
)
788789

@@ -1000,7 +1001,7 @@ def generate_primary_header(self, filename, product, *, version=0):
10001001
("DATAMIN", empty_if_nan(product.dmin), "Minimum valid physical value"),
10011002
("DATAMAX", empty_if_nan(product.dmax), "Maximum valid physical value"),
10021003
("BUNIT", product.bunit, "Units of physical value, after application of BSCALE, BZERO"),
1003-
("XPOSURE", empty_if_nan(product.exposure), "[s] shortest exposure time"),
1004+
("XPOSURE", empty_if_nan(product.min_exposure), "[s] shortest exposure time"),
10041005
("XPOMAX", empty_if_nan(product.max_exposure), "[s] maximum exposure time"),
10051006
)
10061007

stixcore/io/product_processors/tests/test_processors.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,13 +191,13 @@ def test_count_data_mixin(p_file):
191191
p = Product(p_file)
192192
assert p.dmin == p.data["counts"].min().value
193193
assert p.dmax == p.data["counts"].max().value
194-
assert p.exposure == p.data["timedel"].min().as_float().to_value()
194+
assert p.min_exposure == p.data["timedel"].min().as_float().to_value()
195195
assert p.max_exposure == p.data["timedel"].max().as_float().to_value()
196196

197197
test_data = {
198198
"DATAMAX": p.dmax,
199199
"DATAMIN": p.dmin,
200-
"XPOSURE": p.exposure,
200+
"XPOSURE": p.min_exposure,
201201
"XPOMAX": p.max_exposure,
202202
"BUNIT": "counts",
203203
}
@@ -257,7 +257,7 @@ def test_level1_processor_generate_primary_header(product, soop_manager):
257257
product.dmax = 1
258258
product.dunit = ""
259259
product.max_exposure = 1
260-
product.exposure = 1
260+
product.min_exposure = 1
261261
product.service_type = 1
262262
product.service_subtype = 2
263263
product.ssid = 3

stixcore/processing/tests/test_end2end.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ def test_complete(orig_fits, current_fits):
7373
raise ValueError(f"{error_c} errors out of {len(orig_fits)}\nnumber of fits files differ")
7474

7575

76-
@pytest.mark.remote_data
7776
@pytest.mark.end2end
77+
@pytest.mark.remote_data
7878
def test_identical(orig_fits, current_fits):
7979
error_c = 0
8080
error_files = list()

stixcore/processing/tests/test_publish.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ def test_publish_fits_to_esa_incomplete(product, out_dir):
138138
product.date_obs = beg
139139
product.date_beg = beg
140140
product.date_end = end
141-
product.exposure = 2
141+
product.min_exposure = 2
142142
product.max_exposure = 3
143143
product.dmin = 2
144144
product.dmax = 3
@@ -247,7 +247,7 @@ def test_fits_incomplete_switch_over(out_dir):
247247
product.date_obs = beg
248248
product.date_beg = beg
249249
product.date_end = end
250-
product.exposure = 2
250+
product.min_exposure = 2
251251
product.max_exposure = 3
252252
product.dmin = 2
253253
product.dmax = 3
@@ -385,7 +385,7 @@ def test_publish_fits_to_esa(product, out_dir):
385385
product.date_obs = beg
386386
product.date_beg = beg
387387
product.date_end = end
388-
product.exposure = 2
388+
product.min_exposure = 2
389389
product.max_exposure = 3
390390
product.dmin = 2
391391
product.dmax = 3

stixcore/products/CAL/energy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def bunit(self):
8181
return "keV"
8282

8383
@property
84-
def exposure(self):
84+
def min_exposure(self):
8585
# default for FITS HEADER
8686
return self.control["integration_time"].min().to_value(u.s)
8787

stixcore/products/level3/flarelist.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,9 @@ def utc_timerange(self):
499499
@property
500500
def scet_timerange(self):
501501
tr = self.utc_timerange
502+
logger.warning(
503+
"scet_timerange will be approximated using Spice. Better to work with utc_timerange property to avoid automatic time conversion"
504+
)
502505
start = SCETime.from_string(Spice.instance.datetime_to_scet(tr.start)[2:])
503506
end = SCETime.from_string(Spice.instance.datetime_to_scet(tr.end)[2:])
504507
return SCETimeRange(start=start, end=end)
@@ -515,7 +518,7 @@ def dmax(self):
515518
return (self.data["lc_peak"].sum(axis=1)).max().value if len(self.data) > 0 else np.nan
516519

517520
@property
518-
def exposure(self):
521+
def min_exposure(self):
519522
return self.data["duration"].min().to_value("s") if len(self.data) > 0 else np.nan
520523

521524
@property
@@ -633,6 +636,9 @@ def utc_timerange(self):
633636
@property
634637
def scet_timerange(self):
635638
tr = self.utc_timerange
639+
logger.warning(
640+
"scet_timerange will be approximated using Spice. Better to work with utc_timerange property to avoid automatic time conversion"
641+
)
636642
start = SCETime.from_string(Spice.instance.datetime_to_scet(tr.start)[2:])
637643
end = SCETime.from_string(Spice.instance.datetime_to_scet(tr.end)[2:])
638644
return SCETimeRange(start=start, end=end)
@@ -649,7 +655,7 @@ def dmax(self):
649655
return (self.data["lc_peak"].sum(axis=1)).max().value if len(self.data) > 0 else np.nan
650656

651657
@property
652-
def exposure(self):
658+
def min_exposure(self):
653659
return self.data["duration"].min().to_value("s") if len(self.data) > 0 else np.nan
654660

655661
@property

stixcore/products/level3/flarelistproduct.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
from stixcore.ephemeris.manager import Spice
55
from stixcore.products.product import GenericProduct, L3Mixin
66
from stixcore.time.datetime import SCETime, SCETimeRange
7+
from stixcore.util.logging import get_logger
78

89
__all__ = ["FlareListProduct", "PeekPreviewImage"]
910

11+
logger = get_logger(__name__)
12+
1013

1114
class FlareListProduct(GenericProduct, L3Mixin):
1215
"""Product not based on direct TM data but on time ranges defined in flare lists.
@@ -47,6 +50,9 @@ def utc_timerange(self):
4750
@property
4851
def scet_timerange(self):
4952
tr = self.utc_timerange
53+
logger.warning(
54+
"scet_timerange will be approximated using Spice. Better to work with utc_timerange property to avoid automatic time conversion"
55+
)
5056
start = SCETime.from_string(Spice.instance.datetime_to_scet(tr.start)[2:])
5157
end = SCETime.from_string(Spice.instance.datetime_to_scet(tr.end)[2:])
5258
return SCETimeRange(start=start, end=end)

stixcore/products/product.py

Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from itertools import chain
44

55
import numpy as np
6+
import pytz
7+
from sunpy.time.timerange import TimeRange
68
from sunpy.util.datatype_factory_base import (
79
BasicRegistrationFactory,
810
MultipleMatchError,
@@ -17,6 +19,7 @@
1719

1820
import stixcore.processing.decompression as decompression
1921
import stixcore.processing.engineering as engineering
22+
from stixcore.ephemeris.manager import Spice
2023
from stixcore.idb.manager import IDBManager
2124
from stixcore.time import SCETime, SCETimeDelta, SCETimeRange
2225
from stixcore.tmtc.packet_factory import Packet
@@ -47,7 +50,7 @@
4750

4851
# date when the min integration time was changed from 1.0s to 0.5s needed to fix count and time
4952
# offset issue
50-
MIN_INT_TIME_CHANGE = datetime(2021, 9, 6, 13)
53+
MIN_INT_TIME_CHANGE = datetime(2021, 9, 6, 13, tzinfo=pytz.UTC)
5154

5255

5356
def read_qtable(file, hdu, hdul=None):
@@ -211,7 +214,9 @@ def get_cls_processing_version(cls):
211214

212215
class ProductFactory(BasicRegistrationFactory):
213216
def __call__(self, *args, **kwargs):
214-
if len(args) == 1 and len(kwargs) == 0:
217+
get_timeformat_from_TIMESYS = kwargs.get("get_timeformat_from_TIMESYS", False)
218+
219+
if len(args) == 1:
215220
if isinstance(args[0], (str, Path)):
216221
file_path = Path(args[0])
217222
pri_header = fits.getheader(file_path)
@@ -258,8 +263,24 @@ def __call__(self, *args, **kwargs):
258263
ssid = 34
259264

260265
if level not in ["LB", "LL01"] and "timedel" in data.colnames and "time" in data.colnames:
261-
data["timedel"] = SCETimeDelta(data["timedel"])
262-
offset = SCETime.from_float(pri_header["OBT_BEG"] * u.s)
266+
if level in ["L0", "L1"] and not get_timeformat_from_TIMESYS:
267+
# L0 and L1 date are open by default in SCETime format so we can directly apply the timedelta
268+
data["timedel"] = SCETimeDelta(data["timedel"])
269+
offset = SCETime.from_float(pri_header["OBT_BEG"] * u.s)
270+
else:
271+
# in L2 and higher the time format should not be in SCETime format
272+
# select the time format based on available header keywords
273+
offset = None
274+
if pri_header.get("TIMESYS", "") == "UTC":
275+
try:
276+
offset = Time(pri_header["DATE-OBS"])
277+
except ValueError:
278+
offset = None
279+
280+
# fallback to OBT_BEG if no TIMESYS=UTC or DATE-OBS is present or can not be parsed
281+
if offset is None:
282+
offset = SCETime.from_float(pri_header["OBT_BEG"] * u.s)
283+
data["timedel"] = SCETimeDelta(data["timedel"])
263284

264285
try:
265286
control["time_stamp"] = SCETime.from_float(control["time_stamp"])
@@ -535,10 +556,25 @@ def __init__(
535556

536557
@property
537558
def scet_timerange(self):
538-
return SCETimeRange(
539-
start=self.data["time"][0] - self.data["timedel"][0] / 2,
540-
end=self.data["time"][-1] + self.data["timedel"][-1] / 2,
541-
)
559+
if isinstance(self.data["time"], SCETime):
560+
return SCETimeRange(
561+
start=self.data["time"][0] - self.data["timedel"][0] / 2,
562+
end=self.data["time"][-1] + self.data["timedel"][-1] / 2,
563+
)
564+
else:
565+
logger.warning(
566+
"internal time format is not in SCETime format, scet_timerange will be approximated using Spice. Better to work with utc_timerange property to avoid automatic time conversion"
567+
)
568+
start_str = Spice.instance.datetime_to_scet((self.data["time"][0] - self.data["timedel"][0] / 2).datetime)
569+
end_str = Spice.instance.datetime_to_scet((self.data["time"][-1] + self.data["timedel"][-1] / 2).datetime)
570+
if "/" in start_str:
571+
start_str = start_str.split("/")[-1]
572+
if "/" in end_str:
573+
end_str = end_str.split("/")[-1]
574+
return SCETimeRange(
575+
start=SCETime.from_string(start_str),
576+
end=SCETime.from_string(end_str),
577+
)
542578

543579
@property
544580
def raw(self):
@@ -564,7 +600,7 @@ def bunit(self):
564600
return " "
565601

566602
@property
567-
def exposure(self):
603+
def min_exposure(self):
568604
# default for FITS HEADER
569605
return 0.0
570606

@@ -644,6 +680,12 @@ def __add__(self, other):
644680
if not isinstance(other, type(self)):
645681
raise TypeError(f"Products must of same type not {type(self)} and {type(other)}")
646682

683+
if "time" in self.data.colnames and "time" in other.data.colnames:
684+
if type(self.data["time"]) is not type(other.data["time"]):
685+
raise TypeError(
686+
f"Products must have the same time format not {type(self.data['time'])} and {type(other.data['time'])}"
687+
)
688+
647689
# make a deep copy of the data and control
648690
other_control = other.control[:]
649691
other_data = other.data[:]
@@ -667,8 +709,10 @@ def __add__(self, other):
667709

668710
# Fits write we do np.around(time - start_time).as_float().to(u.cs)).astype("uint32"))
669711
# So need to do something similar here to avoid comparing un-rounded value to rounded values
670-
data["time_float"] = np.around((data["time"] - data["time"].min()).as_float().to("cs"))
671-
712+
if isinstance(data["time"], SCETime):
713+
data["time_float"] = np.around((data["time"] - data["time"].min()).as_float().to("cs"))
714+
else: # datetime or Time
715+
data["time_float"] = np.around((data["time"] - data["time"].min()).to("cs"))
672716
# remove duplicate data based on time bin and sort the data
673717
data = unique(data, keys=["time_float"])
674718
# data.sort(["time_float"])
@@ -863,12 +907,18 @@ def bunit(self):
863907
return "counts"
864908

865909
@property
866-
def exposure(self):
867-
return self.data["timedel"].as_float().min().to_value("s")
910+
def min_exposure(self):
911+
if isinstance(self.data["timedel"], SCETimeDelta):
912+
return self.data["timedel"].as_float().min().to_value("s")
913+
else:
914+
return self.data["timedel"].min().to_value("s")
868915

869916
@property
870917
def max_exposure(self):
871-
return self.data["timedel"].as_float().max().to_value("s")
918+
if isinstance(self.data["timedel"], SCETimeDelta):
919+
return self.data["timedel"].as_float().max().to_value("s")
920+
else:
921+
return self.data["timedel"].max().to_value("s")
872922

873923

874924
class EnergyChannelsMixin:
@@ -925,7 +975,13 @@ class L1Mixin(FitsHeaderMixin):
925975

926976
@property
927977
def utc_timerange(self):
928-
return self.scet_timerange.to_timerange()
978+
if isinstance(self.data["time"], SCETime):
979+
return self.scet_timerange.to_timerange()
980+
else:
981+
return TimeRange(
982+
(self.data["time"][0] - self.data["timedel"][0] / 2),
983+
(self.data["time"][-1] + self.data["timedel"][-1] / 2),
984+
)
929985

930986
@classmethod
931987
def from_level0(cls, l0product, parent=""):
@@ -951,10 +1007,10 @@ def from_level0(cls, l0product, parent=""):
9511007
if idbs[0] < (2, 26, 36) and len(l1.data) > 1:
9521008
# Check if request was at min configured time resolution
9531009
if (
954-
l1.utc_timerange.start.datetime < MIN_INT_TIME_CHANGE
1010+
l0product.scet_timerange.start.to_datetime() < MIN_INT_TIME_CHANGE
9551011
and l1.data["timedel"].as_float().min() == 1 * u.s
9561012
) or (
957-
l1.utc_timerange.start.datetime >= MIN_INT_TIME_CHANGE
1013+
l0product.scet_timerange.start.to_datetime() >= MIN_INT_TIME_CHANGE
9581014
and l1.data["timedel"].as_float().min() == 0.5 * u.s
9591015
):
9601016
l1.data["timedel"][1:-1] = l1.data["timedel"][:-2]
@@ -972,7 +1028,13 @@ def from_level0(cls, l0product, parent=""):
9721028
class L2Mixin(FitsHeaderMixin):
9731029
@property
9741030
def utc_timerange(self):
975-
return self.scet_timerange.to_timerange()
1031+
if isinstance(self.data["time"], SCETime):
1032+
return self.scet_timerange.to_timerange()
1033+
else:
1034+
return TimeRange(
1035+
(self.data["time"][0] - self.data["timedel"][0] / 2).datetime,
1036+
(self.data["time"][-1] + self.data["timedel"][-1] / 2).datetime,
1037+
)
9761038

9771039
@classmethod
9781040
def get_additional_extensions(cls):

0 commit comments

Comments
 (0)