"""AM headroom and amplifier-saturation checks for immunity measurements."""
from dataclasses import dataclass
import math
from scuq.quantities import Quantity
from scuq.si import METER, VOLT, WATT
from mpylab.tools.aunits import POWERRATIO
from mpylab.tools.quantity_uncertainty import (
divide_quantities,
magnitude_quantity,
multiply_quantities,
)
from mpylab.tools.uconv import quantity_to_db
EFIELD = VOLT / METER
FORWARD_POWER_METHOD = "forward_power"
FIELD_PROXY_METHOD = "field_proxy"
AM_HEADROOM_METHODS = (FORWARD_POWER_METHOD, FIELD_PROXY_METHOD)
MINIMUM_DROP_DECISION_RULE = "minimum_drop"
NOMINAL_INTERVAL_DECISION_RULE = "nominal_interval"
UPPER_UNCERTAINTY_DECISION_RULE = "upper_uncertainty_overlap"
DEFAULT_MAXIMUM_DROP_DB = 7.1
@dataclass(frozen=True)
class AMHeadroomResult:
"""Structured result of an AM headroom check.
Parameters
----------
status, decision_status : str
Detailed decision outcome. Both fields contain ``passed``,
``passed_with_uncertainty``, ``failed_below_interval``, or
``failed_above_interval``. ``decision_status`` makes the meaning
explicit for serialized consumers.
passed : bool
Whether exposure may proceed under the selected decision rule.
method : str
Measurement method, either ``forward_power`` or ``field_proxy``.
normative : bool
Whether the result uses the normative forward-power method.
factor : float
Peak-to-carrier field factor used for the headroom test.
expected_drop_db : float
Generator reduction corresponding to ``factor``.
minimum_drop_db : float
Minimum accepted headroom reduction in decibels.
maximum_drop_db : float
Maximum accepted headroom reduction in decibels.
actual_drop_db : float
Nominal measured reduction in decibels.
high_value, reduced_value : scuq.quantities.Quantity
Measurements before and after the generator reduction, including
their SCUQ uncertainty components.
reason : str
Human-readable explanation of the decision.
decision_rule : str
Rule used to compare the measurement with the accepted interval.
allow_upper_uncertainty : bool
Whether overlap of the expanded uncertainty interval may satisfy only
the upper boundary. The lower boundary always uses the nominal value.
coverage_factor : float
Multiplier applied to the standard dB uncertainty.
standard_uncertainty_minus_db, standard_uncertainty_plus_db : float
Asymmetric one-standard-uncertainty contributions on the dB scale.
expanded_uncertainty_minus_db, expanded_uncertainty_plus_db : float
Standard uncertainties multiplied by ``coverage_factor``.
decision_interval_lower_db, decision_interval_upper_db : float
Expanded-uncertainty bounds. They are diagnostic unless
``allow_upper_uncertainty`` is true and the nominal value exceeds the
upper boundary.
"""
status: str
passed: bool
method: str
normative: bool
factor: float
expected_drop_db: float
minimum_drop_db: float
maximum_drop_db: float
actual_drop_db: float
high_value: Quantity
reduced_value: Quantity
reason: str
decision_rule: str = NOMINAL_INTERVAL_DECISION_RULE
decision_status: str = "unknown"
allow_upper_uncertainty: bool = False
coverage_factor: float = 1.0
standard_uncertainty_minus_db: float = 0.0
standard_uncertainty_plus_db: float = 0.0
expanded_uncertainty_minus_db: float = 0.0
expanded_uncertainty_plus_db: float = 0.0
decision_interval_lower_db: float = 0.0
decision_interval_upper_db: float = 0.0
def as_dict(self):
"""Return a pickle-friendly representation of the result."""
return {
"status": self.status,
"passed": self.passed,
"method": self.method,
"normative": self.normative,
"factor": self.factor,
"expected_drop_db": self.expected_drop_db,
"minimum_drop_db": self.minimum_drop_db,
"maximum_drop_db": self.maximum_drop_db,
"actual_drop_db": self.actual_drop_db,
"high_value": self.high_value,
"reduced_value": self.reduced_value,
"reason": self.reason,
"decision_rule": self.decision_rule,
"decision_status": self.decision_status,
"allow_upper_uncertainty": self.allow_upper_uncertainty,
"coverage_factor": self.coverage_factor,
"standard_uncertainty_minus_db": self.standard_uncertainty_minus_db,
"standard_uncertainty_plus_db": self.standard_uncertainty_plus_db,
"expanded_uncertainty_minus_db": self.expanded_uncertainty_minus_db,
"expanded_uncertainty_plus_db": self.expanded_uncertainty_plus_db,
"decision_interval_lower_db": self.decision_interval_lower_db,
"decision_interval_upper_db": self.decision_interval_upper_db,
}
[docs]
def am_headroom_factor(modulation_depth_percent):
"""Return the peak-to-carrier field factor for sinusoidal AM.
``modulation_depth_percent`` is expressed in percent. For the usual
80 % modulation depth, the returned factor is 1.8.
"""
try:
depth = float(modulation_depth_percent)
except (TypeError, ValueError) as exc:
raise TypeError("modulation depth must be a real number") from exc
if not math.isfinite(depth) or not 0.0 <= depth <= 100.0:
raise ValueError("modulation depth must be between 0 and 100 percent")
return 1.0 + depth / 100.0
def am_headroom_drop_db(factor):
"""Return the required generator reduction for an AM headroom factor."""
try:
factor = float(factor)
except (TypeError, ValueError) as exc:
raise TypeError("AM headroom factor must be a real number") from exc
if not math.isfinite(factor) or factor <= 1.0:
raise ValueError("AM headroom factor must be finite and greater than one")
return 20.0 * math.log10(factor)
def _positive_quantity_value(value, unit, name):
if not isinstance(value, Quantity):
value = Quantity(unit, value)
value = value.reduce_to(unit)
scalar = value.get_expectation_value_as_float()
if not math.isfinite(scalar) or scalar <= 0.0:
raise ValueError("%s must be finite and positive" % name)
return value, scalar
def corrected_forward_power_at_port(
measurement_graph,
*,
amplifier_output,
power_meter,
waveguide_port,
measured_power,
):
"""Correct a directional-coupler reading to a measurement-system port.
Parameters
----------
measurement_graph : mpylab.tools.mgraph.MGraph
Graph providing power-ratio path corrections.
amplifier_output, power_meter, waveguide_port : str
Node names for the correction reference, meter, and target port.
measured_power : scuq.quantities.Quantity or None
Power reported by the forward-power meter. ``None`` indicates that no
meter result is available.
Returns
-------
scuq.quantities.Quantity or None
Corrected magnitude in watts, with uncertainty preserved, or ``None``.
"""
if measured_power is None:
return None
meter_correction = measurement_graph.get_path_correction(
amplifier_output, power_meter, POWERRATIO
)
port_correction = measurement_graph.get_path_correction(
amplifier_output, waveguide_port, POWERRATIO
)
corrected = multiply_quantities(measured_power, port_correction)
corrected = divide_quantities(corrected, meter_correction)
return magnitude_quantity(corrected).reduce_to(WATT)
def evaluate_am_headroom(
high_value,
reduced_value,
*,
factor=1.8,
method=FORWARD_POWER_METHOD,
drop_tolerance_db=2.0,
maximum_drop_db=DEFAULT_MAXIMUM_DROP_DB,
coverage_factor=1.0,
allow_upper_uncertainty=False,
):
"""Evaluate the signal reduction used to check amplifier saturation.
With the usual factor 1.8, the default nominal acceptance interval is
3.10545 dB through 7.1 dB. The lower boundary demonstrates that amplifier
compression does not exceed 2 dB. The upper default follows the current
IEC 61000-4-3:2020 linearity interval and is also used by mpylab as an
explicit interpretation for IEC 61000-4-20:2022. Other procedures can
select a different ``factor``, ``drop_tolerance_db``, or
``maximum_drop_db``. ``field_proxy`` applies the same interval to field
strengths when forward power is unavailable, but that result is explicitly
marked as non-normative.
Parameters
----------
high_value, reduced_value : scuq.quantities.Quantity or real
Values measured before and after reducing the generator. Forward
power uses watts; the field proxy uses volts per metre. Plain real
values are interpreted in the unit required by ``method`` and carry
zero uncertainty.
factor : float, optional
Peak-to-carrier field factor. The default 1.8 corresponds to 80 % AM.
method : {"forward_power", "field_proxy"}, optional
``forward_power`` evaluates ``10*log10(high/reduced)`` and is marked
normative. ``field_proxy`` evaluates ``20*log10(high/reduced)`` and
is explicitly non-normative.
drop_tolerance_db : float, optional
Amount subtracted from the expected reduction to form the lower
accepted boundary. It must be finite and non-negative.
maximum_drop_db : float, optional
Upper accepted boundary in decibels. The default is 7.1 dB. It must be
finite and not smaller than the calculated lower boundary.
coverage_factor : float, optional
Positive finite multiplier for the asymmetric standard uncertainties.
It affects the reported uncertainty interval. That interval changes a
decision only when ``allow_upper_uncertainty`` is true.
allow_upper_uncertainty : bool, optional
If true, a nominal result above ``maximum_drop_db`` passes as
``passed_with_uncertainty`` when its expanded uncertainty interval
overlaps the upper boundary. The default is false. Uncertainty is
never applied at the lower boundary.
Returns
-------
AMHeadroomResult
Measurement, uncertainty interval, method classification, and
decision outcome.
Raises
------
TypeError
If numeric parameters or quantities have incompatible types.
ValueError
If a method is unknown, a numeric parameter is out of range, or either
measurement is non-positive.
Notes
-----
By default, pass/fail uses only the nominal measured drop at both interval
boundaries. Correlated SCUQ components in ``high_value`` and
``reduced_value`` are still propagated through their ratio for diagnostics
and for the optional upper-bound overlap rule.
"""
if method not in AM_HEADROOM_METHODS:
raise ValueError("method must be one of %s" % ", ".join(AM_HEADROOM_METHODS))
try:
coverage = float(coverage_factor)
except (TypeError, ValueError) as exc:
raise TypeError("coverage_factor must be a real number") from exc
if not math.isfinite(coverage) or coverage <= 0.0:
raise ValueError("coverage_factor must be finite and greater than zero")
expected_drop = am_headroom_drop_db(factor)
try:
tolerance = float(drop_tolerance_db)
except (TypeError, ValueError) as exc:
raise TypeError("drop_tolerance_db must be a real number") from exc
if not math.isfinite(tolerance) or tolerance < 0.0:
raise ValueError("drop_tolerance_db must be finite and non-negative")
try:
maximum_drop = float(maximum_drop_db)
except (TypeError, ValueError) as exc:
raise TypeError("maximum_drop_db must be a real number") from exc
if not math.isfinite(maximum_drop):
raise ValueError("maximum_drop_db must be finite")
if not isinstance(allow_upper_uncertainty, bool):
raise TypeError("allow_upper_uncertainty must be a boolean")
if method == FORWARD_POWER_METHOD:
high, high_scalar = _positive_quantity_value(high_value, WATT, "high forward power")
reduced, reduced_scalar = _positive_quantity_value(
reduced_value, WATT, "reduced forward power"
)
logarithmic_factor = 10.0
else:
high, high_scalar = _positive_quantity_value(high_value, EFIELD, "high field strength")
reduced, reduced_scalar = _positive_quantity_value(
reduced_value, EFIELD, "reduced field strength"
)
logarithmic_factor = 20.0
drop = quantity_to_db(
divide_quantities(high, reduced),
factor=logarithmic_factor,
)
actual_drop = float(drop.value)
standard_minus = float(drop.minus)
standard_plus = float(drop.plus)
expanded_minus = coverage * standard_minus
expanded_plus = coverage * standard_plus
decision_lower = actual_drop - expanded_minus
decision_upper = actual_drop + expanded_plus
minimum_drop = max(0.0, expected_drop - tolerance)
if maximum_drop < minimum_drop:
raise ValueError("maximum_drop_db must not be smaller than the minimum drop")
epsilon = 1e-9
if actual_drop < minimum_drop - epsilon:
passed = False
decision_status = "failed_below_interval"
decision_rule = NOMINAL_INTERVAL_DECISION_RULE
reason = "measured drop is too small; amplifier saturation is likely"
elif actual_drop <= maximum_drop + epsilon:
passed = True
decision_status = "passed"
decision_rule = NOMINAL_INTERVAL_DECISION_RULE
reason = "measured drop is within the accepted AM headroom interval"
elif allow_upper_uncertainty and decision_lower <= maximum_drop + epsilon:
passed = True
decision_status = "passed_with_uncertainty"
decision_rule = UPPER_UNCERTAINTY_DECISION_RULE
reason = (
"nominal drop exceeds the upper boundary, but the expanded "
"uncertainty interval overlaps it"
)
else:
passed = False
decision_status = "failed_above_interval"
decision_rule = (
UPPER_UNCERTAINTY_DECISION_RULE
if allow_upper_uncertainty else NOMINAL_INTERVAL_DECISION_RULE
)
reason = "measured drop exceeds the maximum AM headroom reduction"
return AMHeadroomResult(
status=decision_status,
passed=passed,
method=method,
normative=method == FORWARD_POWER_METHOD,
factor=float(factor),
expected_drop_db=expected_drop,
minimum_drop_db=minimum_drop,
maximum_drop_db=maximum_drop,
actual_drop_db=actual_drop,
high_value=high,
reduced_value=reduced,
reason=reason,
decision_rule=decision_rule,
decision_status=decision_status,
allow_upper_uncertainty=allow_upper_uncertainty,
coverage_factor=coverage,
standard_uncertainty_minus_db=standard_minus,
standard_uncertainty_plus_db=standard_plus,
expanded_uncertainty_minus_db=expanded_minus,
expanded_uncertainty_plus_db=expanded_plus,
decision_interval_lower_db=decision_lower,
decision_interval_upper_db=decision_upper,
)