Source code for mpylab.tools.am_headroom

"""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, )