Coverage for C: \ Users \ peaco \ OneDrive \ Documents \ GitHub \ mt_metadata \ mt_metadata \ common \ band.py: 97%

117 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-10 00:11 -0800

1""" 

2Band class for frequency band definitions. 

3 

4Development Notes: 

5 To add better overlap and intersection checking, consider using piso 

6 https://piso.readthedocs.io/en/latest/getting_started/index.html 

7""" 

8 

9# ===================================================== 

10# Imports 

11# ===================================================== 

12from typing import Annotated, Optional 

13 

14import numpy as np 

15import pandas as pd 

16from pydantic import ( 

17 computed_field, 

18 Field, 

19 field_validator, 

20 model_validator, 

21 ValidationInfo, 

22) 

23 

24from mt_metadata.base import MetadataBase 

25from mt_metadata.common.enumerations import StrEnumerationBase 

26 

27 

28# ===================================================== 

29class CenterAveragingTypeEnum(StrEnumerationBase): 

30 arithmetic = "arithmetic" 

31 geometric = "geometric" 

32 

33 

34class ClosedEnum(StrEnumerationBase): 

35 left = "left" 

36 right = "right" 

37 both = "both" 

38 

39 

40class Band(MetadataBase): 

41 decimation_level: Annotated[ 

42 int, 

43 Field( 

44 default=None, 

45 description="Decimation level for the band", 

46 alias=None, 

47 json_schema_extra={ 

48 "units": None, 

49 "required": True, 

50 "examples": ["0"], 

51 }, 

52 ), 

53 ] 

54 

55 index_max: Annotated[ 

56 int, 

57 Field( 

58 default=None, 

59 description="maximum band index", 

60 alias=None, 

61 json_schema_extra={ 

62 "units": None, 

63 "required": True, 

64 "examples": ["10"], 

65 }, 

66 ), 

67 ] 

68 

69 index_min: Annotated[ 

70 int, 

71 Field( 

72 default=None, 

73 description="minimum band index", 

74 alias=None, 

75 json_schema_extra={ 

76 "units": None, 

77 "required": True, 

78 "examples": ["10"], 

79 }, 

80 ), 

81 ] 

82 

83 frequency_max: Annotated[ 

84 float, 

85 Field( 

86 default=0.0, 

87 description="maximum band frequency", 

88 alias=None, 

89 json_schema_extra={ 

90 "units": "Hertz", 

91 "required": True, 

92 "examples": ["0.04296875"], 

93 }, 

94 ), 

95 ] 

96 

97 frequency_min: Annotated[ 

98 float, 

99 Field( 

100 default=0.0, 

101 description="minimum band frequency", 

102 alias=None, 

103 json_schema_extra={ 

104 "units": "Hertz", 

105 "required": True, 

106 "examples": ["0.03515625"], 

107 }, 

108 ), 

109 ] 

110 

111 center_averaging_type: Annotated[ 

112 CenterAveragingTypeEnum, 

113 Field( 

114 default=CenterAveragingTypeEnum.geometric, 

115 description="type of average to apply when computing the band center", 

116 alias=None, 

117 json_schema_extra={ 

118 "units": None, 

119 "required": True, 

120 "examples": ["geometric"], 

121 }, 

122 ), 

123 ] 

124 

125 closed: Annotated[ 

126 ClosedEnum, 

127 Field( 

128 default=ClosedEnum.left, 

129 description="whether interval is open or closed", 

130 alias=None, 

131 json_schema_extra={ 

132 "units": None, 

133 "required": True, 

134 "examples": ["left"], 

135 }, 

136 ), 

137 ] 

138 

139 name: Annotated[ 

140 Optional[str], 

141 Field( 

142 default="", 

143 description="Name of the band", 

144 alias=None, 

145 json_schema_extra={ 

146 "units": None, 

147 "required": False, 

148 "examples": ["0.039062"], 

149 }, 

150 ), 

151 ] 

152 

153 # we want a name to be generated if not provided, but we also want to allow 

154 # the user to set a name explicitly, so we use a field validator that runs after, 

155 # but we also need a model validator for after. 

156 @field_validator("name", mode="after") 

157 @classmethod 

158 def validate_name(cls, value: Optional[str], info: ValidationInfo) -> str: 

159 if value in ["", None]: 

160 # Generate a default name using available data 

161 if "frequency_min" in info.data and "frequency_max" in info.data: 

162 center_freq = ( 

163 info.data["frequency_min"] + info.data["frequency_max"] 

164 ) / 2 

165 return f"{center_freq:.6f}" 

166 else: 

167 return "unnamed_band" 

168 elif not isinstance(value, str): 

169 raise TypeError(f"Expected string, got {type(value)}") 

170 else: 

171 return value 

172 

173 @field_validator("frequency_min", "frequency_max", mode="after") 

174 @classmethod 

175 def update_name_on_frequency_change( 

176 cls, value: float, info: ValidationInfo 

177 ) -> float: 

178 # This will trigger a model validation after the field is set 

179 return value 

180 

181 @model_validator(mode="after") 

182 def check_name(self) -> "Band": 

183 # Update name if it's the default "empty_band" and we now have frequencies 

184 if self.name in ["", None] or ( 

185 self.name == "empty_band" 

186 and self.frequency_min != 0.0 

187 and self.frequency_max != 0.0 

188 ): 

189 if self.frequency_min != 0.0 and self.frequency_max != 0.0: 

190 center_freq = (self.frequency_min + self.frequency_max) / 2 

191 self.name = f"{center_freq:.6f}" 

192 else: 

193 self.name = "empty_band" 

194 return self 

195 

196 @computed_field 

197 @property 

198 def lower_bound(self) -> float: 

199 return self.frequency_min 

200 

201 @computed_field 

202 @property 

203 def upper_bound(self) -> float: 

204 return self.frequency_max 

205 

206 @computed_field 

207 @property 

208 def width(self) -> float: 

209 """returns the width of the band (the bandwidth).""" 

210 return self.upper_bound - self.lower_bound 

211 

212 @computed_field 

213 @property 

214 def lower_closed(self) -> bool: 

215 return self.to_interval().closed_left 

216 

217 @computed_field 

218 @property 

219 def upper_closed(self) -> bool: 

220 return self.to_interval().closed_right 

221 

222 def _indices_from_frequencies(self, frequencies: np.ndarray) -> np.ndarray: 

223 """ 

224 

225 Parameters 

226 ---------- 

227 frequencies: numpy array 

228 Intended to represent the one-sided (positive) frequency axis of 

229 the data that has been FFT-ed 

230 

231 Returns 

232 ------- 

233 indices: numpy array of integers 

234 Integer indices of the fourier coefficients associated with the 

235 frequecies passed as input argument 

236 """ 

237 if self.lower_closed: 

238 cond1 = frequencies >= self.lower_bound 

239 else: 

240 cond1 = frequencies > self.lower_bound 

241 if self.upper_closed: 

242 cond2 = frequencies <= self.upper_bound 

243 else: 

244 cond2 = frequencies < self.upper_bound 

245 

246 indices = np.where(cond1 & cond2)[0] 

247 return indices 

248 

249 def set_indices_from_frequencies(self, frequencies: np.ndarray) -> None: 

250 """assumes min/max freqs are defined""" 

251 indices = self._indices_from_frequencies(frequencies) 

252 self.index_min = indices[0] 

253 self.index_max = indices[-1] 

254 

255 def to_interval(self): 

256 # Handle both string and enum values for closed 

257 closed_value = ( 

258 self.closed.value if hasattr(self.closed, "value") else self.closed 

259 ) 

260 return pd.Interval(self.frequency_min, self.frequency_max, closed=closed_value) 

261 

262 @property 

263 def harmonic_indices(self): 

264 """ 

265 Assumes all harmoincs between min and max are present in the band 

266 

267 Returns 

268 ------- 

269 numpy array of integers corresponding to harminic indices 

270 """ 

271 return np.arange(self.index_min, self.index_max + 1) 

272 

273 def in_band_harmonics(self, frequencies: np.ndarray): 

274 """ 

275 Parameters 

276 ---------- 

277 frequencies: array-like, floating poirt 

278 

279 Returns: numpy array 

280 the actual harmonics or frequencies in band, rather than the indices. 

281 ------- 

282 

283 """ 

284 indices = self._indices_from_frequencies(frequencies) 

285 harmonics = frequencies[indices] 

286 return harmonics 

287 

288 @property 

289 def center_frequency(self) -> float: 

290 """ 

291 Returns 

292 ------- 

293 center_frequency: float 

294 The frequency associated with the band center. 

295 """ 

296 if self.center_averaging_type == "geometric": 

297 return np.sqrt(self.lower_bound * self.upper_bound) 

298 elif self.center_averaging_type == "arithmetic": 

299 return (self.lower_bound + self.upper_bound) / 2 

300 else: 

301 # Default fallback, could raise an error or return a default value 

302 return float("nan") 

303 

304 @property 

305 def center_period(self) -> float: 

306 """Returns the inverse of center frequency.""" 

307 return 1.0 / self.center_frequency 

308 

309 def overlaps(self, other) -> bool: 

310 """Check if this band overlaps with another""" 

311 ivl = self.to_interval() 

312 other_ivl = other.to_interval() 

313 return ivl.overlaps(other_ivl) 

314 

315 def contains(self, other) -> bool: 

316 """Check if this band contains nother""" 

317 ivl = self.to_interval() 

318 cond1 = ivl.__contains__(other.lower_bound) 

319 cond2 = ivl.__contains__(other.upper_bound) 

320 return cond1 & cond2 

321 

322 @computed_field 

323 @property 

324 def fractional_bandwidth(self) -> float: 

325 """ 

326 See 

327 - https://en.wikipedia.org/wiki/Bandwidth_(signal_processing)#Fractional_bandwidth 

328 - https://en.wikipedia.org/wiki/Q_factor 

329 

330 Returns 

331 ------- 

332 

333 """ 

334 return self.width / self.center_frequency 

335 

336 @computed_field 

337 @property 

338 def Q(self) -> float: 

339 """ 

340 Quality factor (Q) of the band. 

341 

342 Returns 

343 ------- 

344 float 

345 Q factor. Returns infinity for zero-width bands. 

346 """ 

347 if self.fractional_bandwidth == 0.0: 

348 return float("inf") 

349 return 1.0 / self.fractional_bandwidth