Coverage for src/susi/reduc/demodulation/mod_states.py: 68%

145 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2025-08-11 10:03 +0000

1#!/usr/bin/env python3 

2# -*- coding: utf-8 -*- 

3""" 

4In this module the mod state detector can be found 

5 

6@author: hoelken, iglesias (changed algorithm on 20241106) 

7""" 

8from __future__ import annotations 

9 

10import numpy as np 

11import os 

12import matplotlib.pyplot as plt 

13 

14 

15from ...base import InsufficientDataException, Globals, Logging 

16from ...plot import matplot_backend 

17from ...utils import Collections 

18 

19logger = Logging.get_logger() 

20 

21 

22class ModStateDetector: 

23 """ 

24 Class will analyse the header information of the complete batch to detect the state of each frame and to 

25 detect missing frames. 

26 """ 

27 

28 #: Constant defines the expected minimal delta that occurs on a roll-over 

29 ROLLOVER_DELTA = -100 

30 #: Constant that defines the allowed deviation from the mean TS delta 

31 TSTMP_MEAN_DEVIATION = 1.31 

32 #: Constant that defines when we consider two frames lost 

33 TSTMP_DOUBLE_MEAN_DEVIATION = 2.2 

34 #: Constant that defines when to terminate the program due to data corruption 

35 TSTMP_CRITICAL_MEAN_DEVIATION = 3.33 

36 

37 def __init__(self, config, batch): 

38 #: The config data item 

39 self.config = config 

40 #: A loaded (and sorted) batch of all the frames to demodulate (only header information is needed) 

41 self.batch = batch 

42 #: holds the angles per index 

43 self.angles = np.empty(0) 

44 #: holds the time stamps per index 

45 self.tstmps = np.empty(0) 

46 #: holds the time stamp deltas 

47 self.ts_deltas = np.empty(0) 

48 #: holds the median of the time stamp deltas 

49 self.tstmp_median = np.empty(0) 

50 #: holds the state per index 

51 self.states = list() 

52 #: holds the integer total number of modulation states 

53 self.no_mod_cycles = np.empty(0) 

54 

55 def analyze(self) -> ModStateDetector: 

56 """ 

57 Executes the analysis 

58 :return: self 

59 """ 

60 self.__get_orig_angles() 

61 self.__get_orig_tstmps() 

62 self.__correct_missing_frames() 

63 self.__get_ini_modstate() 

64 self.__build_state_map() 

65 return self 

66 

67 def __get_orig_angles(self) -> None: 

68 # Fetches the (uncorrected) index of angles from the fits batch 

69 self.angles = Collections.as_float_array(self.batch.header_field(self.config.spol.pmu_ang_field)) 

70 self.no_mod_cycles = len(self.angles) // Globals.MOD_CYCLE_FRAMES 

71 if self.no_mod_cycles < self.config.spol.min_mod_cycles: 

72 req_time = self.config.spol.min_mod_cycles * Globals.MOD_CYCLE_FRAMES * Globals.SAMP_S 

73 logger.warning( 

74 'Not enough mod cycles, need at least %s (%s s) but got %s', 

75 self.config.spol.min_mod_cycles, 

76 req_time, 

77 self.no_mod_cycles, 

78 ) 

79 raise InsufficientDataException('Not enough mod cycles found to demodulate the data.') 

80 

81 def __get_orig_tstmps(self) -> None: 

82 # Fetches the timestamp of all frames 

83 self.tstmps = Collections.as_float_array( 

84 self.batch.header_field(self.config.base.timestamp_field), dtype=np.float64 

85 ) 

86 self.ts_deltas = np.diff(self.tstmps) 

87 self.tstmp_median = np.median(self.ts_deltas) 

88 

89 def __correct_missing_frames(self) -> None: 

90 # Check for timestamps deltas that are too large 

91 tota_frames_lost = np.sum(self.ts_deltas > self.tstmp_median * ModStateDetector.TSTMP_MEAN_DEVIATION) 

92 if tota_frames_lost > 0: 

93 logger.warning('Found %s frames lost out a total of %s', tota_frames_lost, len(self.ts_deltas)) 

94 block_gaps = [] 

95 for i in range(0, self.no_mod_cycles): 

96 block_start = i * Globals.MOD_CYCLE_FRAMES 

97 block_end = block_start + Globals.MOD_CYCLE_FRAMES 

98 gap_id, gap_frames = self.__check_timestamps(block_start, block_end) 

99 if gap_id is not None: 

100 block_gaps.append([i, gap_id, gap_frames]) 

101 if len(block_gaps) == 0: 

102 return None 

103 logger.warning('Found %s mod cycle(s) with missing frames.', len(block_gaps)) 

104 self._delete_mod_cycles(block_gaps) 

105 

106 def __check_timestamps(self, start_id, end_id): 

107 ts_deltas = self.ts_deltas[start_id:end_id] 

108 if any(delta > self.tstmp_median * ModStateDetector.TSTMP_CRITICAL_MEAN_DEVIATION for delta in ts_deltas): 

109 logger.critical( 

110 'Found too many frames lost from "%s" to "%s".', 

111 self.batch.file_by(start_id), 

112 self.batch.file_by(end_id), 

113 ) 

114 self.__plot_on_error() 

115 raise InsufficientDataException('Too many frames lost. See log for details.') 

116 gap_id = np.sort(np.argwhere(ts_deltas > self.tstmp_median * ModStateDetector.TSTMP_MEAN_DEVIATION).flatten()) 

117 if len(gap_id) > 1: 

118 raise InsufficientDataException('Found more than one timestamp gap in a single mod cycle. Not supported.') 

119 if len(gap_id) == 0: 

120 return None, None 

121 gap_id = gap_id[0] 

122 gap_frames = round(ts_deltas[gap_id] / self.tstmp_median) - 1 

123 return gap_id, gap_frames 

124 

125 def _delete_mod_cycles(self, block_gaps): 

126 # Eliminate the mod cycles with missing frames to ensure a continuous modulation state sequence 

127 for i, gap in enumerate(block_gaps): 

128 # remove frames to ensure the full block with missing frames is removed 

129 # so the next block starts at the same modulation state as the first block 

130 mod_cyc_id = gap[0] 

131 if mod_cyc_id == 0: 

132 logger.debug('First mod cycle has missing frames. Removing it.') 

133 gap_id = gap[1] 

134 gap_frames = gap[2] 

135 start_id = mod_cyc_id * Globals.MOD_CYCLE_FRAMES 

136 no_frames_to_remove = Globals.MOD_CYCLE_FRAMES - gap_frames 

137 end_id = start_id + no_frames_to_remove 

138 logger.warning('Processing mod cycle #%s that has %s missing frame(s)', mod_cyc_id, gap_frames) 

139 self.__remove_block(start_id, end_id) 

140 # corrects the mod_cyc_id of the following blocks with gaps 

141 for j in range(i + 1, len(block_gaps)): 

142 block_gaps[j][0] -= 1 

143 

144 def __remove_block(self, start_id, end_id): 

145 logger.warning('Removing %s files from %s to %s', end_id - start_id, start_id, end_id) 

146 self.angles = np.delete(self.angles, range(start_id, end_id)) 

147 self.no_mod_cycles -= 1 

148 for i in range(start_id, end_id): 

149 logger.debug('\t#%s: %s [IGNORED]', i, self.batch.file_by(start_id)) 

150 self.batch.batch.pop(start_id) 

151 

152 def __get_ini_modstate(self) -> None: 

153 # Determines the location of modulation state 0. 

154 # Defined as the state after the 360->0 roll over of the mean angle. 

155 # No frame lost is assumed !! 

156 # uses only a multiple of Globals.MOD_CYCLE_FRAMES 

157 angles = self.angles[0 : self.no_mod_cycles * Globals.MOD_CYCLE_FRAMES] 

158 angles_avg = angles.reshape(-1, Globals.MOD_CYCLE_FRAMES).mean(axis=0) 

159 angles_avg = np.append(angles_avg[-1], angles_avg) 

160 ang_diff = np.diff(angles_avg) 

161 logger.debug('self.no_mod_cycles %s', self.no_mod_cycles) 

162 self.ini_modstate_roll = np.argmax(np.abs(ang_diff)) 

163 if self.ini_modstate_roll == 0: 

164 ini_modstate = 0 

165 else: 

166 ini_modstate = Globals.MOD_CYCLE_FRAMES - self.ini_modstate_roll 

167 logger.info('Initial modulation state found is: %s', ini_modstate) 

168 

169 def __build_state_map(self) -> None: 

170 # Create an array with length/mod_cycle_frames blocks of 0,..,11 

171 self.states = np.tile( 

172 np.arange(Globals.MOD_CYCLE_FRAMES), np.int64(np.ceil(len(self.angles) / Globals.MOD_CYCLE_FRAMES)) 

173 ) 

174 # shift the first state according to the initial modulation state location 

175 self.states = np.roll(self.states, self.ini_modstate_roll) 

176 self.states = self.states[0 : len(self.angles)] 

177 

178 def __plot_on_error(self): 

179 # TODO Move to src/susi/analyse ? 

180 # Plots relevant header quantities to help debugging lost frames or state ambiguity issues 

181 # timestamp vs frame (as input, not necessarily sorted) 

182 if self.config.data.log_dir is None: 

183 return 

184 

185 with matplot_backend('Agg'): 

186 fig = plt.figure(figsize=(15, 7)) 

187 fig.suptitle('') 

188 plt.plot(self.tstmps, '*') 

189 plt.xlabel('Frame') 

190 plt.xlabel('PMU_ANG [deg]') 

191 ofile = self.config.data.log_dir + '/susi_mod_states_warning_TIMESTSAMPS.png' 

192 plt.savefig(ofile) 

193 plt.close() 

194 # tmestamp difference 

195 fig = plt.figure(figsize=(15, 7)) 

196 fig.suptitle('') 

197 plt.plot(np.diff(self.tstmps), '*') 

198 plt.xlabel('Frame') 

199 plt.xlabel('Timestamp difference [us]') 

200 ofile = self.config.data.log_dir + '/susi_mod_states_warning_TIMESTSAMPS_DIFF.png' 

201 plt.savefig(ofile) 

202 plt.close() 

203 # PMU_ANG vs timestamp 

204 if len(self.states) > 0: 

205 fig = plt.figure(figsize=(15, 7)) 

206 fig.suptitle('') 

207 plt.plot(self.tstmps, self.angles, '*') 

208 plt.xlabel(r'Timestamps [$\mu$s]') 

209 plt.ylabel('PMU_ANG [deg]') 

210 ofile = self.config.data.log_dir + '/susi_mod_states_warning_PMU_ANG.png' 

211 plt.savefig(ofile) 

212 plt.close() 

213 # Identified mod state vs timestamp 

214 if len(self.states) > 0 and len(self.states) == len(self.tstmps): 

215 fig = plt.figure(figsize=(15, 7)) 

216 fig.suptitle('') 

217 plt.plot(self.tstmps, self.states, '*') 

218 plt.xlabel(r'Timestamps [$\mu$s]') 

219 plt.ylabel('Detected modulation state') 

220 ofile = self.config.data.log_dir + '/susi_mod_states_warning_MOD_STATES.png' 

221 plt.savefig(ofile) 

222 plt.close() 

223 logger.warning('Saved some usefull plots to %s', os.path.dirname(ofile) + '/susi_mod_states_warning_...png')