Coverage for src / tracekit / compliance / masks.py: 88%

61 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 23:04 +0000

1"""EMC regulatory limit masks. 

2 

3This module provides limit mask loading and management for EMC compliance testing. 

4 

5 

6Example: 

7 >>> from tracekit.compliance.masks import load_limit_mask, AVAILABLE_MASKS 

8 >>> print(AVAILABLE_MASKS) 

9 >>> mask = load_limit_mask('FCC_Part15_ClassB') 

10 >>> print(f"Frequency range: {mask.frequency_range}") 

11 

12References: 

13 FCC Part 15 (47 CFR Part 15) 

14 CISPR 22/32 (EN 55022/55032) 

15 MIL-STD-461G 

16""" 

17 

18from __future__ import annotations 

19 

20import json 

21from dataclasses import dataclass, field 

22from pathlib import Path 

23from typing import TYPE_CHECKING, Any 

24 

25import numpy as np 

26 

27if TYPE_CHECKING: 

28 from numpy.typing import NDArray 

29 

30 

31@dataclass 

32class LimitMask: 

33 """EMC limit mask definition. 

34 

35 Attributes: 

36 name: Standard name (e.g., 'FCC_Part15_ClassB') 

37 description: Human-readable description 

38 frequency: Frequency points in Hz 

39 limit: Limit values in dBuV (or specified unit) 

40 unit: Limit unit ('dBuV', 'dBm', 'dBuV/m') 

41 standard: Standard designation (e.g., 'FCC Part 15B', 'CISPR 32') 

42 distance: Measurement distance in meters 

43 detector: Required detector type ('peak', 'quasi-peak', 'average') 

44 frequency_range: (min, max) frequency in Hz 

45 regulatory_body: Regulatory body (FCC, CE, MIL) 

46 document: Reference document 

47 """ 

48 

49 name: str 

50 frequency: NDArray[np.float64] 

51 limit: NDArray[np.float64] 

52 description: str = "" 

53 unit: str = "dBuV" 

54 standard: str = "" 

55 distance: float = 3.0 # meters 

56 detector: str = "peak" 

57 regulatory_body: str = "" 

58 document: str = "" 

59 metadata: dict[str, Any] = field(default_factory=dict) 

60 

61 @property 

62 def frequency_range(self) -> tuple[float, float]: 

63 """Return (min, max) frequency range.""" 

64 return (float(self.frequency.min()), float(self.frequency.max())) 

65 

66 def get_limit_at_frequency(self, frequency: float) -> float: 

67 """Get limit value at a specific frequency. 

68 

69 Args: 

70 frequency: Frequency in Hz. 

71 

72 Returns: 

73 Limit value at the specified frequency (interpolated if needed). 

74 """ 

75 return float(np.interp(frequency, self.frequency, self.limit)) 

76 

77 def interpolate(self, frequencies: NDArray[np.float64]) -> NDArray[np.float64]: 

78 """Interpolate limit values at given frequencies. 

79 

80 Args: 

81 frequencies: Frequency points to interpolate to. 

82 

83 Returns: 

84 Interpolated limit values. 

85 """ 

86 return np.interp(frequencies, self.frequency, self.limit) 

87 

88 def to_dict(self) -> dict[str, Any]: 

89 """Convert to dictionary for serialization.""" 

90 return { 

91 "name": self.name, 

92 "description": self.description, 

93 "frequency": self.frequency.tolist(), 

94 "limit": self.limit.tolist(), 

95 "unit": self.unit, 

96 "distance": self.distance, 

97 "detector": self.detector, 

98 "regulatory_body": self.regulatory_body, 

99 "document": self.document, 

100 "metadata": self.metadata, 

101 } 

102 

103 @classmethod 

104 def from_dict(cls, data: dict[str, Any]) -> LimitMask: 

105 """Create from dictionary.""" 

106 return cls( 

107 name=data["name"], 

108 description=data.get("description", ""), 

109 frequency=np.array(data["frequency"]), 

110 limit=np.array(data["limit"]), 

111 unit=data.get("unit", "dBuV"), 

112 distance=data.get("distance", 3.0), 

113 detector=data.get("detector", "peak"), 

114 regulatory_body=data.get("regulatory_body", ""), 

115 document=data.get("document", ""), 

116 metadata=data.get("metadata", {}), 

117 ) 

118 

119 

120# Built-in EMC limit masks 

121_BUILTIN_MASKS: dict[str, dict[str, Any]] = { 

122 # FCC Part 15 - Unintentional Radiators (Radiated Emissions) 

123 "FCC_Part15_ClassA": { 

124 "description": "FCC Part 15 Class A (Commercial) Radiated Emissions", 

125 "frequency": np.array([30, 88, 216, 960, 1000]) * 1e6, # Hz 

126 "limit": np.array([49.5, 54, 56.9, 60, 60]), # dBuV/m at 10m 

127 "unit": "dBuV/m", 

128 "distance": 10.0, 

129 "detector": "quasi-peak", 

130 "regulatory_body": "FCC", 

131 "document": "47 CFR 15.109", 

132 }, 

133 "FCC_Part15_ClassB": { 

134 "description": "FCC Part 15 Class B (Residential) Radiated Emissions", 

135 "frequency": np.array([30, 88, 216, 960, 1000]) * 1e6, 

136 "limit": np.array([40, 43.5, 46, 54, 54]), # dBuV/m at 3m 

137 "unit": "dBuV/m", 

138 "distance": 3.0, 

139 "detector": "quasi-peak", 

140 "regulatory_body": "FCC", 

141 "document": "47 CFR 15.109", 

142 }, 

143 "FCC_Part15_ClassB_Conducted": { 

144 "description": "FCC Part 15 Class B Conducted Emissions", 

145 "frequency": np.array([0.15, 0.5, 5, 30]) * 1e6, 

146 "limit": np.array([66, 56, 56, 60]), # dBuV quasi-peak 

147 "unit": "dBuV", 

148 "distance": 0, 

149 "detector": "quasi-peak", 

150 "regulatory_body": "FCC", 

151 "document": "47 CFR 15.107", 

152 }, 

153 # CE/CISPR - European Standards 

154 "CE_CISPR22_ClassA": { 

155 "description": "CISPR 22 Class A (Commercial) Radiated Emissions", 

156 "frequency": np.array([30, 230, 1000]) * 1e6, 

157 "limit": np.array([40, 47, 47]), # dBuV/m at 10m 

158 "unit": "dBuV/m", 

159 "distance": 10.0, 

160 "detector": "quasi-peak", 

161 "regulatory_body": "CE", 

162 "document": "EN 55022 / CISPR 22", 

163 }, 

164 "CE_CISPR22_ClassB": { 

165 "description": "CISPR 22 Class B (Residential) Radiated Emissions", 

166 "frequency": np.array([30, 230, 1000]) * 1e6, 

167 "limit": np.array([30, 37, 37]), # dBuV/m at 10m 

168 "unit": "dBuV/m", 

169 "distance": 10.0, 

170 "detector": "quasi-peak", 

171 "regulatory_body": "CE", 

172 "document": "EN 55022 / CISPR 22", 

173 }, 

174 "CE_CISPR32_ClassA": { 

175 "description": "CISPR 32 Class A (Commercial) Radiated Emissions", 

176 "frequency": np.array([30, 230, 1000]) * 1e6, 

177 "limit": np.array([40, 47, 47]), 

178 "unit": "dBuV/m", 

179 "distance": 10.0, 

180 "detector": "quasi-peak", 

181 "regulatory_body": "CE", 

182 "document": "EN 55032 / CISPR 32", 

183 }, 

184 "CE_CISPR32_ClassB": { 

185 "description": "CISPR 32 Class B (Residential) Radiated Emissions", 

186 "frequency": np.array([30, 230, 1000]) * 1e6, 

187 "limit": np.array([30, 37, 37]), 

188 "unit": "dBuV/m", 

189 "distance": 10.0, 

190 "detector": "quasi-peak", 

191 "regulatory_body": "CE", 

192 "document": "EN 55032 / CISPR 32", 

193 }, 

194 "CE_CISPR32_ClassB_Conducted": { 

195 "description": "CISPR 32 Class B Conducted Emissions", 

196 "frequency": np.array([0.15, 0.5, 5, 30]) * 1e6, 

197 "limit_qp": np.array([66, 56, 56, 60]), # Quasi-peak 

198 "limit_avg": np.array([56, 46, 46, 50]), # Average 

199 "limit": np.array([66, 56, 56, 60]), # Use QP as default 

200 "unit": "dBuV", 

201 "distance": 0, 

202 "detector": "quasi-peak", 

203 "regulatory_body": "CE", 

204 "document": "EN 55032 / CISPR 32", 

205 }, 

206 # MIL-STD-461G - Military Standards 

207 "MIL_STD_461G_CE102": { 

208 "description": "MIL-STD-461G CE102 Conducted Emissions (10kHz-10MHz)", 

209 "frequency": np.array([0.01, 0.15, 0.5, 2, 10]) * 1e6, 

210 "limit": np.array([94, 80, 80, 80, 80]), # dBuV 

211 "unit": "dBuV", 

212 "distance": 0, 

213 "detector": "peak", 

214 "regulatory_body": "MIL", 

215 "document": "MIL-STD-461G", 

216 }, 

217 "MIL_STD_461G_RE102": { 

218 "description": "MIL-STD-461G RE102 Radiated Emissions (2MHz-18GHz)", 

219 "frequency": np.array([2, 30, 100, 200, 1000, 18000]) * 1e6, 

220 "limit": np.array([54, 54, 34, 34, 34, 34]), # dBuV/m at 1m 

221 "unit": "dBuV/m", 

222 "distance": 1.0, 

223 "detector": "peak", 

224 "regulatory_body": "MIL", 

225 "document": "MIL-STD-461G", 

226 }, 

227 "MIL_STD_461G_CS101": { 

228 "description": "MIL-STD-461G CS101 Conducted Susceptibility", 

229 "frequency": np.array([0.03, 0.15, 50]) * 1e6, 

230 "limit": np.array([6, 6, 6]), # Vrms 

231 "unit": "Vrms", 

232 "distance": 0, 

233 "detector": "average", 

234 "regulatory_body": "MIL", 

235 "document": "MIL-STD-461G", 

236 }, 

237} 

238 

239# List of available mask names 

240AVAILABLE_MASKS = list(_BUILTIN_MASKS.keys()) 

241 

242# Mask aliases for convenience 

243_MASK_ALIASES: dict[str, str] = { 

244 "CISPR11_ClassB": "CE_CISPR22_ClassB", # CISPR 11 is similar to CISPR 22 

245 "CISPR22": "CE_CISPR22_ClassB", # Short alias for CISPR 22 Class B 

246 "CISPR22_ClassB": "CE_CISPR22_ClassB", 

247 "CISPR22_ClassA": "CE_CISPR22_ClassA", 

248 "CISPR32": "CE_CISPR32_ClassB", # Short alias for CISPR 32 Class B 

249 "CISPR32_ClassB": "CE_CISPR32_ClassB", 

250 "CISPR32_ClassA": "CE_CISPR32_ClassA", 

251} 

252 

253 

254def load_limit_mask( 

255 name: str, 

256 custom_path: str | Path | None = None, 

257) -> LimitMask: 

258 """Load an EMC limit mask by name. 

259 

260 Args: 

261 name: Mask name (see AVAILABLE_MASKS) or path to custom mask file. 

262 custom_path: Optional path to directory containing custom mask files. 

263 

264 Returns: 

265 LimitMask object. 

266 

267 Raises: 

268 ValueError: If mask name is unknown and no custom file found. 

269 

270 Example: 

271 >>> mask = load_limit_mask('FCC_Part15_ClassB') 

272 >>> print(f"Limit at 100MHz: {mask.interpolate(np.array([100e6]))[0]:.1f} dBuV/m") 

273 """ 

274 # Resolve aliases 

275 resolved_name = _MASK_ALIASES.get(name, name) 

276 

277 # Check built-in masks first 

278 if resolved_name in _BUILTIN_MASKS: 

279 mask_data = _BUILTIN_MASKS[resolved_name].copy() 

280 return LimitMask(name=name, **mask_data) 

281 

282 # Check custom path 

283 if custom_path is not None: 283 ↛ 284line 283 didn't jump to line 284 because the condition on line 283 was never true

284 custom_file = Path(custom_path) / f"{name}.json" 

285 if custom_file.exists(): 

286 with open(custom_file) as f: 

287 data = json.load(f) 

288 return LimitMask.from_dict(data) 

289 

290 # Try loading as JSON file path 

291 if Path(name).exists(): 

292 with open(name) as f: 

293 data = json.load(f) 

294 return LimitMask.from_dict(data) 

295 

296 raise ValueError(f"Unknown limit mask: {name}. Available masks: {', '.join(AVAILABLE_MASKS)}") 

297 

298 

299def create_custom_mask( 

300 name: str, 

301 frequencies: list[float] | NDArray[np.float64], 

302 limits: list[float] | NDArray[np.float64], 

303 unit: str = "dBuV", 

304 description: str = "", 

305 **kwargs: Any, 

306) -> LimitMask: 

307 """Create a custom limit mask. 

308 

309 Args: 

310 name: Mask name. 

311 frequencies: Frequency points in Hz. 

312 limits: Limit values in specified unit. 

313 unit: Limit unit ('dBuV', 'dBm', 'dBuV/m'). 

314 description: Human-readable description. 

315 **kwargs: Additional LimitMask attributes. 

316 

317 Returns: 

318 LimitMask object. 

319 

320 Raises: 

321 ValueError: If frequencies and limits have different lengths. 

322 

323 Example: 

324 >>> mask = create_custom_mask( 

325 ... name="MyLimit", 

326 ... frequencies=[30e6, 100e6, 1000e6], 

327 ... limits=[40, 35, 30], 

328 ... unit="dBuV/m", 

329 ... description="Custom radiated limit" 

330 ... ) 

331 """ 

332 freq_array = np.array(frequencies) 

333 limit_array = np.array(limits) 

334 

335 # Validate lengths match 

336 if len(freq_array) != len(limit_array): 

337 raise ValueError( 

338 f"frequencies and limits must have the same length " 

339 f"(got {len(freq_array)} frequencies and {len(limit_array)} limits)" 

340 ) 

341 

342 # Auto-sort by frequency if not sorted 

343 if len(freq_array) > 1 and not np.all(np.diff(freq_array) > 0): 

344 sort_indices = np.argsort(freq_array) 

345 freq_array = freq_array[sort_indices] 

346 limit_array = limit_array[sort_indices] 

347 

348 return LimitMask( 

349 name=name, 

350 description=description, 

351 frequency=freq_array, 

352 limit=limit_array, 

353 unit=unit, 

354 **kwargs, 

355 ) 

356 

357 

358__all__ = [ 

359 "AVAILABLE_MASKS", 

360 "LimitMask", 

361 "create_custom_mask", 

362 "load_limit_mask", 

363]