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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""EMC regulatory limit masks.
3This module provides limit mask loading and management for EMC compliance testing.
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}")
12References:
13 FCC Part 15 (47 CFR Part 15)
14 CISPR 22/32 (EN 55022/55032)
15 MIL-STD-461G
16"""
18from __future__ import annotations
20import json
21from dataclasses import dataclass, field
22from pathlib import Path
23from typing import TYPE_CHECKING, Any
25import numpy as np
27if TYPE_CHECKING:
28 from numpy.typing import NDArray
31@dataclass
32class LimitMask:
33 """EMC limit mask definition.
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 """
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)
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()))
66 def get_limit_at_frequency(self, frequency: float) -> float:
67 """Get limit value at a specific frequency.
69 Args:
70 frequency: Frequency in Hz.
72 Returns:
73 Limit value at the specified frequency (interpolated if needed).
74 """
75 return float(np.interp(frequency, self.frequency, self.limit))
77 def interpolate(self, frequencies: NDArray[np.float64]) -> NDArray[np.float64]:
78 """Interpolate limit values at given frequencies.
80 Args:
81 frequencies: Frequency points to interpolate to.
83 Returns:
84 Interpolated limit values.
85 """
86 return np.interp(frequencies, self.frequency, self.limit)
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 }
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 )
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}
239# List of available mask names
240AVAILABLE_MASKS = list(_BUILTIN_MASKS.keys())
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}
254def load_limit_mask(
255 name: str,
256 custom_path: str | Path | None = None,
257) -> LimitMask:
258 """Load an EMC limit mask by name.
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.
264 Returns:
265 LimitMask object.
267 Raises:
268 ValueError: If mask name is unknown and no custom file found.
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)
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)
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)
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)
296 raise ValueError(f"Unknown limit mask: {name}. Available masks: {', '.join(AVAILABLE_MASKS)}")
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.
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.
317 Returns:
318 LimitMask object.
320 Raises:
321 ValueError: If frequencies and limits have different lengths.
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)
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 )
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]
348 return LimitMask(
349 name=name,
350 description=description,
351 frequency=freq_array,
352 limit=limit_array,
353 unit=unit,
354 **kwargs,
355 )
358__all__ = [
359 "AVAILABLE_MASKS",
360 "LimitMask",
361 "create_custom_mask",
362 "load_limit_mask",
363]