From 51d841fe9351ba25a9047110a03f387592c77722 Mon Sep 17 00:00:00 2001 From: Bernhard Trinnes Date: Wed, 9 Sep 2026 11:05:32 +0200 Subject: [PATCH 1/3] Record the detector a verified point was measured with acquireSegment() coerces a sweep to what the instrument can actually do before sending it, so on an instrument that has no quasi-peak detector a Phase 2 dwell is measured with the peak detector instead. verifyPeak() then recorded the detector that had been *asked* for, and every verified point in the CSV, the JSON and the PDF claimed a quasi-peak measurement the instrument never made. Peak reads at or above quasi-peak, so the numbers were conservative rather than optimistic and no equipment would have passed on them. The label was the wrong part: a report whose one job is to say how a level was measured cannot name a detector that was not used. The resolution bandwidth was already taken from the coerced parameters, which is why an instrument snapping 120 kHz to its nearest bandwidth was reported honestly while the detector beside it was not. All three now come from the same place, and the second coerce() call goes away. --- src/core/engine/MeasurementEngine.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/core/engine/MeasurementEngine.cpp b/src/core/engine/MeasurementEngine.cpp index 911717e..132a288 100644 --- a/src/core/engine/MeasurementEngine.cpp +++ b/src/core/engine/MeasurementEngine.cpp @@ -292,9 +292,16 @@ Result MeasurementEngine::verifyPeak(const PeakCandidate& cand point.marginDb = margin.marginDb; point.rawUnit = trace->unit; point.unit = resultingUnit(trace->unit, m_config.corrections); - point.detector = params.detector; - point.rbw = m_capabilities.coerce(params).rbw; - point.vbw = params.vbw; + // Record what was measured, not what was asked for. acquireSegment() coerces + // the request to what the instrument can do before sending it, so on one + // lacking a quasi-peak detector or a CISPR bandwidth the two differ -- and a + // point labelled with a detector the instrument never used would be wrong + // about the one thing the report exists to record. The bandwidth was already + // taken from here; the detector and video bandwidth were not. + const SweepParams measured = m_capabilities.coerce(params); + point.detector = measured.detector; + point.rbw = measured.rbw; + point.vbw = measured.vbw; point.dwell = m_config.dwellTime; point.corrections = applied; point.limitName = From 6d07a8ca7a9f2da040f7833605f05bebcf67bcd3 Mon Sep 17 00:00:00 2001 From: Bernhard Trinnes Date: Wed, 9 Sep 2026 11:06:19 +0200 Subject: [PATCH 2/3] Support the UNI-T UTS3032T+, in both of its personalities The instrument is two analyzers behind one *IDN? reply, selected with :INSTrument:SELect, and which one is in front decides whether it can make a CISPR measurement at all. Both are verified against a UTS3032T+ on firmware V1.04, and each gets a driver. In "SA" it is an ordinary swept analyzer, driven by ScpiAnalyzerDriver: it answers the standard SCPI spelling for everything PeakEmi uses, so the dialect is the generic one unchanged, and its point count is writable where the Siglent's is fixed. What it does not have is a quasi-peak detector or any of the CISPR 16-1-1 bandwidths -- asked for 9 kHz it tunes 10 kHz, asked for 120 kHz it tunes 100 kHz, and it reports no error either time. The profile lists only the bandwidths it really has, which is what turns that silent substitution into a run refused with a reason. In "EMI" it is a receiver, under a command tree (:SENSe:FSCan:...) that shares almost nothing with the other mode, so it gets a driver of its own rather than another dialect. There the CISPR bandwidths are exact and the quasi-peak and CISPR-average detectors exist, which is what this application is for, so it is the driver chosen automatically: it names the model exactly and outscores the family pattern the SA driver keeps. Ask for --driver unitrend.uts3000t to get the plain analyzer, which scans faster but cannot make a CISPR measurement, and which stays the automatic choice for an unverified UTS3* whose EMI option may be absent. Three things about the EMI personality shaped that driver: * It goes mute while it scans -- nothing is answered, *IDN? included, and commands sent during a scan are discarded rather than queued. So *OPC? is useless: before a scan it returns immediately and means nothing, during one it never answers. A finished scan announces itself by the instrument answering again, which is what armAndTrigger waits for. Queries that timed out meanwhile are still answered, late, so the line is drained by reading before the caller's next query; clear() is not enough, because it only discards what has already been pumped into the socket. * A dwell is a different instrument function. Sweeping is the scan list; dwelling on one frequency is the meter, a fixed-tuned receiver with its own detector, dwell and peak hold, which is what a quasi-peak measurement needs. A zero-span request is routed there and a swept one to the scan list. The meter reports -inf until its detector has formed a result -- about a second for peak, two and a half for quasi-peak -- so the reading is polled for rather than taken when the dwell timer expires, and neither -inf nor the 9.91e+37 "not measuring" sentinel is ever returned as a level. * It has no automatic attenuation, and it does not compensate a wrong one: it simply measures its own noise floor that much higher while the trace still looks plausible. A leftover 51 dB read forty decibels high on an open input. The driver therefore always writes an attenuation, including when the caller asked for "automatic", where a documented 10 dB stands in. Closing leaves this instrument stopped rather than sweeping, which is the opposite of what the other drivers do and is deliberate: a continuous EMI scan never yields the bus, so an instrument left running that way cannot be reached over the network at all and takes the front panel to recover. Two smaller changes fall out of the same work. The amplitude unit is now sent before the reference level, because an instrument that reads the level in whatever unit is currently selected -- this one -- would otherwise take a dBuV number as dBm and clamp it, which is a wrong reference level and not an error. And profilesCoverTheSupportedModels now demands the CISPR bandwidths only of a profile that also claims the quasi-peak detector: claiming one without the other is the combination that produces a measurement labelled quasi-peak and taken at whatever bandwidth was nearest, while claiming neither is an honest description of an instrument that cannot do the job. The scripted transport answers each query once, as an instrument does, so a driver that drains until a read fails can be tested at all. --- README.md | 18 +- src/drivers/BuiltInDrivers.cpp | 18 + src/drivers/CMakeLists.txt | 2 + .../peakemi/drivers/InstrumentProfiles.h | 7 + .../peakemi/drivers/ScpiAnalyzerDriver.h | 1 + .../peakemi/drivers/UnitrendFscanDriver.h | 125 ++++ src/drivers/scpi/InstrumentProfiles.cpp | 166 ++++- src/drivers/scpi/ScpiAnalyzerDriver.cpp | 19 +- src/drivers/scpi/UnitrendFscanDriver.cpp | 629 ++++++++++++++++++ tests/fixtures/ScriptedTransport.h | 16 + tests/unit/DriversTest.cpp | 427 +++++++++++- 11 files changed, 1417 insertions(+), 11 deletions(-) create mode 100644 src/drivers/include/peakemi/drivers/UnitrendFscanDriver.h create mode 100644 src/drivers/scpi/UnitrendFscanDriver.cpp diff --git a/README.md b/README.md index 21b0254..23ad1c2 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,22 @@ QT_QPA_PLATFORM=offscreen ./build/debug/bin/peakemi_screenshots docs/images --da selection, bounded and opt-in LAN sweep, live USB hotplug detection, serial port enumeration, an optional VISA path, and a raw SCPI console. * **Supported analyzers**: Siglent SSA3021X/3032X/3075X and SVA1015X/1032X/1075X, Rigol - DSA705/710 and DSA815/832/875, each with its own frequency range, point count and - command dialect. + DSA705/710 and DSA815/832/875, and the UNI-T UTS3032T+, each with its own frequency + range, point count and command dialect. The UTS3032T+ is driven in its spectrum-analyzer + mode, which has no quasi-peak detector and none of the CISPR 16-1-1 bandwidths, and the + driver declares that: a run asking for either is refused with a reason rather than + measured at whatever the instrument would have substituted. The instrument also has a + separate EMI mode, driven by a second driver (`unitrend.uts3000t.emi`), which is the + one chosen automatically: there the four CISPR 16-1-1 bandwidths (200 Hz, 9 kHz, + 120 kHz, 1 MHz) and the quasi-peak and CISPR-average detectors are available, and a + Phase 2 dwell is measured by the instrument's fixed-tuned receiver. Ask for + `--driver unitrend.uts3000t` to use the plain spectrum-analyzer mode instead, which + scans faster but cannot make a CISPR measurement. + + A CISPR-conformant dwell needs the verification span set to 0 (`--verification-span 0`, + or the Verification span field in the run configuration), which is what routes Phase 2 + to the receiver. Over a swept verification span the dwell is divided across its points + instead of being spent at the frequency, which matters for impulsive emissions. * **Python driver plugins**: in a build configured with `PEAKEMI_WITH_PYTHON=ON`, a driver can be a single Python file loaded into the embedded interpreter, and the measurement engine cannot tell it from one written in C++. A plugin is diff --git a/src/drivers/BuiltInDrivers.cpp b/src/drivers/BuiltInDrivers.cpp index ffaef0d..9e366de 100644 --- a/src/drivers/BuiltInDrivers.cpp +++ b/src/drivers/BuiltInDrivers.cpp @@ -1,5 +1,6 @@ #include #include +#include #include namespace peakemi::drivers { @@ -22,6 +23,23 @@ void registerBuiltInDrivers() hal::DriverRegistry::Entry{.info = makeRigolDsaDriver()->info(), .matcher = hal::makeMatcher("Rigol", {"DSA7*", "DSA8*"}), .factory = &makeRigolDsaDriver}); + + // Both UNI-T drivers claim the same box, because it really is two + // instruments behind one *IDN?. The EMI one names the model exactly and so + // scores higher, which makes it the automatic choice -- it is the mode with + // the quasi-peak detector, and this application exists to make CISPR + // measurements. The spectrum-analyzer mode stays reachable by asking for + // "unitrend.uts3000t" by name, and remains the automatic choice for a + // UTS3* this build has not verified, whose EMI option may not be fitted. + registry.registerDriver( + hal::DriverRegistry::Entry{.info = makeUnitrendFscanDriver()->info(), + .matcher = hal::makeMatcher("UNI-T", {"UTS3032T+", "UTS3032T"}), + .factory = &makeUnitrendFscanDriver}); + + registry.registerDriver( + hal::DriverRegistry::Entry{.info = makeUnitrendUtsDriver()->info(), + .matcher = hal::makeMatcher("UNI-T", {"UTS3*"}), + .factory = &makeUnitrendUtsDriver}); } } // namespace peakemi::drivers diff --git a/src/drivers/CMakeLists.txt b/src/drivers/CMakeLists.txt index f51d784..f924f35 100644 --- a/src/drivers/CMakeLists.txt +++ b/src/drivers/CMakeLists.txt @@ -3,11 +3,13 @@ peakemi_add_module(drivers BuiltInDrivers.cpp scpi/InstrumentProfiles.cpp scpi/ScpiAnalyzerDriver.cpp + scpi/UnitrendFscanDriver.cpp simulated/SimulatedDriver.cpp HEADERS include/peakemi/drivers/InstrumentProfiles.h include/peakemi/drivers/ScpiAnalyzerDriver.h include/peakemi/drivers/SimulatedDriver.h + include/peakemi/drivers/UnitrendFscanDriver.h PUBLIC peakemi::core peakemi::hal diff --git a/src/drivers/include/peakemi/drivers/InstrumentProfiles.h b/src/drivers/include/peakemi/drivers/InstrumentProfiles.h index 97fd735..d9ab2fe 100644 --- a/src/drivers/include/peakemi/drivers/InstrumentProfiles.h +++ b/src/drivers/include/peakemi/drivers/InstrumentProfiles.h @@ -37,6 +37,13 @@ struct InstrumentProfile [[nodiscard]] std::optional profileFor(std::string_view vendor, std::string_view model); +/// The profile of the UNI-T UTS3000T's EMI personality, which is a different +/// instrument from its spectrum-analyzer mode: the CISPR 16-1-1 bandwidths and +/// the quasi-peak and CISPR-average detectors, none of which the SA mode has. +/// Driven by UnitrendFscanDriver rather than by a ScpiDialect, so the dialect +/// member is left at its defaults and unused. +[[nodiscard]] InstrumentProfile unitrendEmiProfile(); + /// The profile a driver falls back to before it has identified the instrument: /// the widest capabilities of the family, so nothing is rejected that a later, /// narrower profile would have allowed. diff --git a/src/drivers/include/peakemi/drivers/ScpiAnalyzerDriver.h b/src/drivers/include/peakemi/drivers/ScpiAnalyzerDriver.h index 8c3f3e6..8d320a6 100644 --- a/src/drivers/include/peakemi/drivers/ScpiAnalyzerDriver.h +++ b/src/drivers/include/peakemi/drivers/ScpiAnalyzerDriver.h @@ -105,5 +105,6 @@ class ScpiAnalyzerDriver : public AbstractAnalyzerDriver /// Instantiate the drivers of the v1 supported set (requirements 6, Q1). [[nodiscard]] DriverPtr makeSiglentSsaDriver(); [[nodiscard]] DriverPtr makeRigolDsaDriver(); +[[nodiscard]] DriverPtr makeUnitrendUtsDriver(); } // namespace peakemi::drivers diff --git a/src/drivers/include/peakemi/drivers/UnitrendFscanDriver.h b/src/drivers/include/peakemi/drivers/UnitrendFscanDriver.h new file mode 100644 index 0000000..56da81b --- /dev/null +++ b/src/drivers/include/peakemi/drivers/UnitrendFscanDriver.h @@ -0,0 +1,125 @@ +#pragma once + +#include + +#include +#include +#include + +namespace peakemi::drivers { + +/// The UNI-T UTS3000T series driven through its EMI option rather than its +/// spectrum-analyzer mode. +/// +/// The instrument has two personalities, selected with ":INSTrument:SELect". +/// In "SA" it is an ordinary swept analyzer with a 1-3-10 bandwidth ladder and +/// no quasi-peak detector; ScpiAnalyzerDriver drives that one. In "EMI" it is a +/// receiver: the CISPR 16-1-1 bandwidths (200 Hz, 9 kHz, 120 kHz, 1 MHz) and +/// the quasi-peak and CISPR-average detectors, which is what a pre-compliance +/// measurement actually needs. The two modes share almost no commands, so this +/// is a driver of its own rather than another ScpiDialect. +/// +/// Two behaviours of the EMI personality shape everything below, and both were +/// measured on a UTS3032T+ running firmware V1.04: +/// +/// * **It goes mute while it scans.** Not "busy", not an error -- it answers +/// nothing at all, "*IDN?" included, and commands sent during a scan are +/// discarded rather than queued. "*OPC?" is therefore useless here: issued +/// before the scan it answers immediately and means nothing, issued during +/// one it is dropped and never answers. The scan is complete when the +/// instrument starts answering again, so that is what armAndTrigger() waits +/// for (see waitUntilResponsive). +/// * **A Phase 2 dwell is a different instrument function.** Sweeping is the +/// scan list; dwelling on one frequency is the "meter", a fixed-tuned +/// receiver with its own detector, dwell time and peak hold. A zero-span +/// request is routed to the meter and a swept one to the scan list, which is +/// why configureSweep() branches on the span width. +/// +/// That branch decides whether a quasi-peak number means what CISPR says it +/// means, so it is worth being blunt about. The quasi-peak and CISPR-average +/// detectors have charge and discharge time constants measured in hundreds of +/// milliseconds, and the dwell has to be spent *at the frequency* for the +/// reading to settle. The meter does that. A swept range does not: it divides +/// the scan time across its points, so verifying a peak over the engine's +/// default 200 kHz verification span with 101 points spends about a hundredth +/// of the dwell on each one and reads low -- the direction that turns a failing +/// emission into a passing number. +/// +/// A run that wants a CISPR-conformant Phase 2 on this instrument therefore +/// wants a verification span of 0, which is what routes the dwell to the meter. +/// The driver does not silently rewrite a swept request into a meter one: +/// measuring something other than what was asked for is the failure this whole +/// class of bug is made of. It measures what it was told to, and this comment +/// is here so the choice is made deliberately. +class UnitrendFscanDriver final : public AbstractAnalyzerDriver +{ +public: + UnitrendFscanDriver(DriverInfo info, Capabilities capabilities); + ~UnitrendFscanDriver() override; + + [[nodiscard]] DriverInfo info() const override { return m_info; } + + [[nodiscard]] Capabilities capabilities() const override { return m_capabilities; } + + [[nodiscard]] Status open(TransportPtr transport) override; + [[nodiscard]] bool isOpen() const override; + void close() override; + + [[nodiscard]] Result identify() override; + [[nodiscard]] Status configureSweep(const SweepParams& params) override; + [[nodiscard]] Status armAndTrigger(const CancelToken& cancel) override; + [[nodiscard]] Result fetchTrace(const CancelToken& cancel) override; + void abort() override; + [[nodiscard]] std::vector lastErrors() override; + void setTimeout(std::chrono::milliseconds timeout) override; + + /// The keyword this instrument spells the detector with, or empty for one + /// it does not have. Exposed for the tests, which assert that a CISPR + /// average is not quietly sent as a plain average. + [[nodiscard]] static std::string detectorKeyword(Detector detector); + + /// How long after the dwell the meter may take to produce its first real + /// reading before the point is failed. The default is generous because the + /// cost of overrunning it is one failed point, while cutting it short means + /// reporting a level the detector had not finished forming. Worth lowering + /// only when a caller would rather fail fast than wait. + void setMeterSettleBudget(std::chrono::milliseconds budget) { m_meterSettleBudget = budget; } + +private: + [[nodiscard]] Status send(const std::string& command); + [[nodiscard]] Status sendValue(const std::string& command, const std::string& value); + [[nodiscard]] Result query(const std::string& command, const CancelToken& cancel); + + [[nodiscard]] Status configureScan(const SweepParams& params); + [[nodiscard]] Status configureMeter(const SweepParams& params); + + /// Block until the instrument answers a query again, which is how a + /// finished scan announces itself. Polls so the token stays effective. + [[nodiscard]] Status waitUntilResponsive(const CancelToken& cancel, + std::chrono::milliseconds limit); + + [[nodiscard]] Result fetchScanTrace(const CancelToken& cancel); + [[nodiscard]] Result fetchMeterReading(const CancelToken& cancel); + + DriverInfo m_info; + Capabilities m_capabilities; + TransportPtr m_transport; + SweepParams m_params; + InstrumentId m_identity; + /// True when the configured acquisition is a meter dwell rather than a scan. + bool m_meterMode{false}; + std::chrono::milliseconds m_timeout{5000}; + /// How long after the dwell the meter is given to produce its first real + /// reading. Measured: peak takes about a second from the peak-hold reset, + /// quasi-peak about two and a half. Generous, because overrunning it costs + /// a failed point while cutting it short would report a level the detector + /// had not finished forming. + std::chrono::milliseconds m_meterSettleBudget{15000}; + std::atomic_bool m_abortRequested{false}; + CancelToken m_abortToken; +}; + +/// The EMI-mode driver for the UNI-T UTS3000T series. +[[nodiscard]] DriverPtr makeUnitrendFscanDriver(); + +} // namespace peakemi::drivers diff --git a/src/drivers/scpi/InstrumentProfiles.cpp b/src/drivers/scpi/InstrumentProfiles.cpp index a050998..47de2a7 100644 --- a/src/drivers/scpi/InstrumentProfiles.cpp +++ b/src/drivers/scpi/InstrumentProfiles.cpp @@ -59,6 +59,37 @@ namespace { megahertz(1)}; } +/// Resolution and video bandwidths of the UNI-T UTS3000T series: a plain +/// 1-3-10 sequence from 1 Hz to 1 MHz, verified against a UTS3032T+ running +/// firmware V1.04 by setting each value and reading it back. +/// +/// The CISPR 16-1-1 bandwidths are absent from this list because they are absent +/// from this mode: asked for 9 kHz it tunes 10 kHz, asked for 120 kHz it tunes +/// 100 kHz, and it reports no error either time. Listing only what it really has +/// is what turns that silent substitution into a refused run with a reason, +/// which is the whole point of declaring bandwidths here. +/// +/// The instrument does have the CISPR bandwidths -- exactly 200 Hz, 9 kHz and +/// 120 kHz -- but only in its separate EMI mode (":INSTrument:SELect EMI"), +/// whose settings live under a different command tree and are not reachable +/// from the spectrum-analyzer mode this driver drives. See unitrendCapabilities. +[[nodiscard]] std::vector unitrendBandwidths() +{ + return {hertz(1), + hertz(3), + hertz(10), + hertz(30), + hertz(100), + hertz(300), + kilohertz(1), + kilohertz(3), + kilohertz(10), + kilohertz(30), + kilohertz(100), + kilohertz(300), + megahertz(1)}; +} + [[nodiscard]] std::vector videoBandwidths() { return {hertz(1), @@ -105,6 +136,65 @@ namespace { return dialect; } +/// The UTS3000T answers the standard SCPI spelling for every setting PeakEmi +/// uses, so this is the generic dialect unchanged. Each command in ScpiDialect +/// was sent to a UTS3032T+ on firmware V1.04 and read back, including the ones +/// the other two families had to override: sweep points are writable here, and +/// the trace comes from ":TRACe:DATA? TRACE1" in ASCII. +[[nodiscard]] ScpiDialect unitrendDialect() +{ + // The quasi-peak and RMS keywords inherited from the defaults are never + // sent: the capabilities below do not offer those detectors, so a sweep + // asking for one is refused before a command is built. + return ScpiDialect{}; +} + +/// What a UTS3000T can do, measured rather than read off a datasheet. +/// +/// These describe the instrument's spectrum-analyzer mode, which is the mode this +/// driver drives. Two gaps matter for pre-compliance work and are the reason this +/// family gets its own capabilities rather than borrowing another's: there is no +/// quasi-peak detector -- ":SENSe:DETector:FUNCtion QPEak" is answered with -224, +/// "Illegal parameter value" -- and none of the CISPR bandwidths (see +/// unitrendBandwidths). Declaring the gaps is what gets a run refused with an +/// actionable message instead of quietly measured with the wrong detector at the +/// wrong bandwidth. +/// +/// The gaps belong to the mode, not to the instrument. Switched to EMI mode with +/// ":INSTrument:SELect EMI", a UTS3032T+ carrying the EMI option offers the CISPR +/// bandwidths and, through "[:SENSe]:FSCan:FINal:DETector", the quasi-peak and +/// CISPR-average detectors with a per-detector dwell of 1 ms to 60 s. That mode is +/// a different command tree -- its own scan table, peak search, final measurement +/// and result fetch -- so driving it means a driver of its own rather than another +/// dialect here. Until that exists, PeakEmi cannot make a CISPR-conformant Phase 2 +/// dwell on this instrument, and the capabilities below say so honestly. +[[nodiscard]] Capabilities unitrendCapabilities(Hertz maximumFrequency, bool trackingGenerator) +{ + return Capabilities{.range = FrequencyRange{hertz(9000), maximumFrequency}, + // Sweep points are writable and the trace honours them; + // outside 11 to 10001 the instrument clamps silently. + .minimumPoints = 11, + .maximumPoints = 10001, + // No quasi-peak, and no RMS: both are refused with -224. + .detectors = {Detector::Peak, Detector::Average, Detector::Sample}, + .resolutionBandwidths = unitrendBandwidths(), + .videoBandwidths = unitrendBandwidths(), + .minimumAttenuation = decibel(0.0), + .maximumAttenuation = decibel(51.0), + .attenuationStep = decibel(1.0), + // The reference level is held in dBm and converted, so in + // the dBuV PeakEmi asks for, its -100 to +30 dBm span + // lands on 6.99 to 136.99. Rounded inwards, because a + // value this list accepts must be one the instrument + // takes without clamping it. + .minimumRefLevel = decibel(7.0), + .maximumRefLevel = decibel(136.9), + .preamp = true, + .trackingGenerator = trackingGenerator, + .zeroSpan = true, + .nativeUnit = AmplitudeUnit::dBuV}; +} + [[nodiscard]] Capabilities siglentCapabilities(Hertz maximumFrequency, bool trackingGenerator) { return Capabilities{.range = FrequencyRange{hertz(9000), maximumFrequency}, @@ -223,6 +313,18 @@ namespace { .capabilities = rigolCapabilities(gigahertz(1.0), false), .dialect = rigolDialect()}); + // --- UNI-T UTS3000T ----------------------------------------------------- + // Only the model on the bench is listed. The rest of the series differs in + // its upper frequency, which is the one number that must not be guessed: + // too high and a sweep is sent that the instrument silently clamps, too low + // and one it could have made is refused. An unlisted UTS3* keeps the family + // defaults until someone verifies it against hardware. + profiles.push_back({.name = "UNI-T UTS3032T+", + .vendor = "UNI-T", + .models = {"UTS3032T+", "UTS3032T"}, + .capabilities = unitrendCapabilities(gigahertz(3.2), true), + .dialect = unitrendDialect()}); + return profiles; } @@ -264,13 +366,73 @@ std::optional profileFor(std::string_view vendor, std::string return std::nullopt; } +InstrumentProfile unitrendEmiProfile() +{ + return { + .name = "UNI-T UTS3032T+ (EMI)", + .vendor = "UNI-T", + .models = {"UTS3032T+", "UTS3032T"}, + .capabilities = + Capabilities{ + .range = FrequencyRange{hertz(9000), gigahertz(3.2)}, + .minimumPoints = 11, + .maximumPoints = 10001, + // Measured on the instrument: POSitive, QPEak and CAVerage + // are accepted on both the scan traces and the meters. + // There is no RMS and no sample detector in this mode. + .detectors = {Detector::Peak, Detector::QuasiPeak, Detector::Average}, + // Exactly the CISPR 16-1-1 set and nothing else -- the EMI + // personality replaces the SA mode's 1-3-10 ladder with the + // four bandwidths the standard mandates, one per band: + // 200 Hz for band A, 9 kHz for B, 120 kHz for C and D, + // 1 MHz for E. Anything else is snapped to one of these. + .resolutionBandwidths = {hertz(200), kilohertz(9), kilohertz(120), megahertz(1)}, + // The EMI personality has no video-bandwidth setting at + // all. An empty list means the engine never validates one, + // which is right: there is nothing here to get wrong. + .videoBandwidths = {}, + // The scan list is the binding constraint: it documents an + // even value from 0 to 50 dB, where the meter takes any + // integer to 51. The narrower of the two is declared so a + // configuration is not accepted here and then quietly + // rounded by the instrument. + .minimumAttenuation = decibel(0.0), + .maximumAttenuation = decibel(50.0), + .attenuationStep = decibel(2.0), + // Nor is there a reference level: the scan list has no + // ":RLEVel" and the query is unanswered. The bounds are + // opened wide rather than left at the defaults so that a + // configuration carrying a reference level for some other + // instrument is not rejected over a setting this one does + // not have and this driver never sends. + .minimumRefLevel = decibel(-200.0), + .maximumRefLevel = decibel(200.0), + .preamp = true, + // The tracking generator belongs to the SA personality. + .trackingGenerator = false, + // Zero span is the meter, which is a better dwell than a + // zero-span sweep would have been. + .zeroSpan = true, + .nativeUnit = AmplitudeUnit::dBuV}, + .dialect = {}}; +} + InstrumentProfile familyProfile(std::string_view vendor) { // Before *IDN? has been asked, assume the widest member of the family: a // narrower profile takes over as soon as the instrument names itself, and // assuming too little here would reject spans the instrument supports. - const bool rigol = QString::fromUtf8(vendor.data(), static_cast(vendor.size())) - .contains(QStringLiteral("Rigol"), Qt::CaseInsensitive); + const auto reported = QString::fromUtf8(vendor.data(), static_cast(vendor.size())); + // "UNI-TREND" is what the instrument puts in *IDN?; "UNI-T" is what the + // front panel and the manuals say, so both have to lead here. + if (reported.contains(QStringLiteral("UNI-T"), Qt::CaseInsensitive)) { + return {.name = "UNI-T UTS3000T", + .vendor = "UNI-T", + .models = {"UTS3*"}, + .capabilities = unitrendCapabilities(gigahertz(3.2), true), + .dialect = unitrendDialect()}; + } + const bool rigol = reported.contains(QStringLiteral("Rigol"), Qt::CaseInsensitive); if (rigol) { return {.name = "Rigol DSA700/DSA800", .vendor = "Rigol", diff --git a/src/drivers/scpi/ScpiAnalyzerDriver.cpp b/src/drivers/scpi/ScpiAnalyzerDriver.cpp index d1ded39..4c3b158 100644 --- a/src/drivers/scpi/ScpiAnalyzerDriver.cpp +++ b/src/drivers/scpi/ScpiAnalyzerDriver.cpp @@ -159,10 +159,14 @@ Status ScpiAnalyzerDriver::configureSweep(const SweepParams& requested) {m_dialect.stopFrequency, scpi::formatHertz(requested.span.stop)}, {m_dialect.sweepPoints, std::to_string(requested.points)}, {m_dialect.detector, detectorKeyword(requested.detector)}, - {m_dialect.referenceLevel, scpi::formatDecibel(requested.refLevel)}, + // The unit goes first: instruments that take the reference level in + // whatever unit is currently selected -- the UNI-T UTS3000T is one -- + // would otherwise read the dBuV number as dBm and clamp it to the top + // of the scale, which is a wrong reference level and not an error. {m_dialect.amplitudeUnit, m_capabilities.nativeUnit == AmplitudeUnit::dBm ? m_dialect.dBmKeyword : m_dialect.dBuVKeyword}, + {m_dialect.referenceLevel, scpi::formatDecibel(requested.refLevel)}, {m_dialect.preamp, requested.preamp ? "ON" : "OFF"}, }; for (const auto& [command, value] : steps) { @@ -335,4 +339,17 @@ DriverPtr makeRigolDsaDriver() return std::make_shared(info, family.capabilities, family.dialect); } +DriverPtr makeUnitrendUtsDriver() +{ + const auto family = familyProfile("UNI-T"); + const DriverInfo info{ + .id = "unitrend.uts3000t", + .name = family.name, + .vendor = "UNI-T", + .version = "1.0", + .origin = "built-in", + .supportedTransports = {TransportKind::Tcp, TransportKind::Vxi11, TransportKind::UsbTmc}}; + return std::make_shared(info, family.capabilities, family.dialect); +} + } // namespace peakemi::drivers diff --git a/src/drivers/scpi/UnitrendFscanDriver.cpp b/src/drivers/scpi/UnitrendFscanDriver.cpp new file mode 100644 index 0000000..87bb4f8 --- /dev/null +++ b/src/drivers/scpi/UnitrendFscanDriver.cpp @@ -0,0 +1,629 @@ +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace peakemi::drivers { +namespace { + +/// The EMI personality, and the scan-list slot this driver uses. +/// +/// The instrument holds ten scan ranges. PeakEmi's engine already splits a run +/// into segments and asks for one span at a time, so the driver keeps a single +/// range and disables the rest rather than mirroring the engine's segmentation +/// into the instrument, where the two could disagree about what was measured. +constexpr auto EmiMode = "EMI"; +constexpr int ScanSlot = 1; +constexpr int ScanSlots = 10; +/// The meter used for a dwell. The instrument has three, which could measure +/// peak, quasi-peak and CISPR average in one pass; the engine asks for one +/// detector at a time, so only the first is enabled and the others are left off +/// (they read back the 9.91e+37 "not measuring" sentinel). +constexpr int MeterSlot = 1; +/// What a meter reports when it is switched off: the SCPI "not a number". +constexpr double NotMeasuring = 9.9e37; + +[[nodiscard]] std::string scanNode(std::string_view leaf) +{ + return ":SENSe:FSCan:SCAN" + std::to_string(ScanSlot) + std::string{leaf}; +} + +[[nodiscard]] std::string seconds(std::chrono::milliseconds duration) +{ + return std::to_string(static_cast(duration.count()) / 1000.0); +} + +/// Input attenuation this driver will actually set, in dB. +/// +/// The EMI personality has no automatic attenuation -- neither the scan list +/// nor the meter offers an AUTO switch, unlike the SA mode's +/// ":SENSe:POWer:RF:ATTenuation:AUTO". So "automatic" cannot be passed on, and +/// the one thing the driver must not do is leave whatever the last operator or +/// the last run happened to set: the instrument does not compensate a wrong +/// attenuation, it just measures its own noise floor that much higher, and the +/// trace looks perfectly plausible while reading tens of dB high. A fixed, +/// documented default is the honest substitute -- 10 dB, which is what the +/// instrument itself powers up with and what SA mode uses. +constexpr int AutomaticAttenuationDb = 10; + +[[nodiscard]] int attenuationDb(const SweepParams& params, int maximum, bool evenOnly) +{ + const double wanted = params.automaticAttenuation ? static_cast(AutomaticAttenuationDb) + : params.attenuation.value(); + int value = static_cast(std::lround(wanted)); + if (evenOnly) { + // The scan list documents its attenuation as an even number; an odd one + // is accepted and silently rounded, so it is rounded here instead, + // where the choice is visible. + value -= value % 2; + } + return std::clamp(value, 0, maximum); +} + +} // namespace + +UnitrendFscanDriver::UnitrendFscanDriver(DriverInfo info, Capabilities capabilities) + : m_info{std::move(info)} + , m_capabilities{std::move(capabilities)} +{} + +UnitrendFscanDriver::~UnitrendFscanDriver() +{ + UnitrendFscanDriver::abort(); + UnitrendFscanDriver::close(); +} + +std::string UnitrendFscanDriver::detectorKeyword(Detector detector) +{ + switch (detector) { + case Detector::Peak: + return "POSitive"; + case Detector::QuasiPeak: + return "QPEak"; + case Detector::Average: + // CAVerage is the CISPR (EMI) average detector, not the plain + // linear average the instrument also offers as AVERage. In a mode + // that exists to make CISPR measurements, and against limit lines + // whose "average" columns are written for the CISPR average + // detector, CAVerage is what "average" has to mean -- a plain + // average here would read low against those limits and pass + // equipment that does not comply. + return "CAVerage"; + case Detector::Rms: + case Detector::Sample: + // Not offered by the EMI personality; capabilities() omits them, so + // a sweep asking for one is refused before this is reached. + return {}; + } + return {}; +} + +Status UnitrendFscanDriver::open(TransportPtr transport) +{ + if (!transport) { + return fail(ErrorCode::InvalidConfiguration, "no transport supplied"); + } + m_transport = std::move(transport); + if (!m_transport->isOpen()) { + if (auto status = m_transport->open(); !status) { + return status; + } + } + m_abortRequested.store(false); + m_abortToken.reset(); + + // Switching personality reconfigures the instrument and it stops answering + // while it does, so the mode change is followed by a wait rather than a + // query that would be dropped. + if (auto status = sendValue(":INSTrument:SELect", EmiMode); !status) { + return status; + } + if (auto status = waitUntilResponsive(m_abortToken, std::chrono::seconds{20}); !status) { + return status; + } + + auto mode = query(":INSTrument:SELect?", m_abortToken); + if (!mode) { + return std::unexpected(mode.error()); + } + if (mode->find("EMI") == std::string::npos) { + return fail(ErrorCode::UnsupportedSetting, + "the instrument did not enter EMI mode (it reports '" + *mode + + "'); the EMI/CISPR option may not be installed"); + } + + if (auto status = send("*CLS"); !status) { + return status; + } + // Stop whatever the front panel was doing and take single-shot control. The + // scan and the meter have separate run control: INITiate2 is the scan, + // INITiate1 the meter. + if (auto status = send(":INITiate:STOP"); !status) { + return status; + } + if (auto status = sendValue(":INITiate2:CONTinuous", "OFF"); !status) { + return status; + } + // The scan trace honours this; the meter does not and always reports dBm, + // which fetchMeterReading converts. + return sendValue(":UNIT:POWer", "DBUV"); +} + +bool UnitrendFscanDriver::isOpen() const +{ + return m_transport && m_transport->isOpen(); +} + +void UnitrendFscanDriver::close() +{ + if (!m_transport) { + return; + } + // Deliberately *not* left scanning, which is the opposite of what the SA + // drivers do on close (FR-RUN-5, NFR-UX-2) and is worth explaining. + // + // A swept analyzer still answers SCPI between sweeps, so leaving it running + // costs nothing. The EMI personality does not: while it scans it is deaf as + // well as mute, and a continuous scan never ends, so there is no gap in + // which a command can land. Leaving this instrument scanning on disconnect + // makes it unreachable over the network entirely -- the next run cannot + // even connect, and it takes the front panel or a power cycle to get it + // back. A static display is a small price for an instrument that still + // answers, and the operator can start a scan from the front panel. + if (auto status = send(":INITiate:STOP"); !status) { + qCDebug(lcDriver) << "could not stop the scan on close:" + << QString::fromStdString(status.error().message()); + } + if (auto status = sendValue(":INITiate2:CONTinuous", "OFF"); !status) { + qCDebug(lcDriver) << "could not leave single-scan mode set:" + << QString::fromStdString(status.error().message()); + } + m_transport->close(); + m_transport.reset(); +} + +Status UnitrendFscanDriver::send(const std::string& command) +{ + if (!isOpen()) { + return fail(ErrorCode::NotConnected, m_info.name); + } + return m_transport->write(command); +} + +Status UnitrendFscanDriver::sendValue(const std::string& command, const std::string& value) +{ + return send(command + ' ' + value); +} + +Result UnitrendFscanDriver::query(const std::string& command, + const CancelToken& cancel) +{ + if (!isOpen()) { + return fail(ErrorCode::NotConnected, m_info.name); + } + return m_transport->query(command, m_timeout, cancel); +} + +Result UnitrendFscanDriver::identify() +{ + if (!m_identity.raw.empty()) { + return m_identity; + } + auto response = query("*IDN?", m_abortToken); + if (!response) { + return std::unexpected(response.error()); + } + m_identity = scpi::parseIdn(*response); + return m_identity; +} + +Status UnitrendFscanDriver::waitUntilResponsive(const CancelToken& cancel, + std::chrono::milliseconds limit) +{ + // A scan makes the instrument mute, so "it answered" is the completion + // signal. Each probe clears the transport first: a reply to an earlier + // probe that arrived just as the scan ended would otherwise be read as the + // answer to this one and desynchronise every later query. + constexpr auto probeTimeout = std::chrono::milliseconds{700}; + // Long enough that a reply already on its way is seen, short enough that + // draining an empty line costs one of these and no more. + constexpr auto drainTimeout = std::chrono::milliseconds{250}; + constexpr int maximumDrainReads = 64; + const auto deadline = std::chrono::steady_clock::now() + limit; + while (std::chrono::steady_clock::now() < deadline) { + if (cancel.isCancelled() || m_abortRequested.load()) { + return fail(ErrorCode::Cancelled, "aborted while waiting for the instrument"); + } + if (!isOpen()) { + return fail(ErrorCode::NotConnected, m_info.name); + } + m_transport->clear(); + if (m_transport->query("*IDN?", probeTimeout, cancel)) { + // One probe answering is not yet a clean line. Every probe that + // timed out while the instrument was mute is still answered, just + // late, so a backlog of replies is queued behind this one and the + // caller's next query would read the oldest of them instead of its + // own answer. Draining has to be done by reading rather than by + // clear(): clear() only discards what has already been pumped into + // the socket object, and these replies are still in flight. Reading + // until a read times out leaves the line genuinely empty. + // Bounded: one read per probe that could have gone unanswered, + // plus a margin. An unbounded loop would hang here against an + // instrument that chatters rather than falling silent. + for (int drained = 0; drained < maximumDrainReads; ++drained) { + if (cancel.isCancelled() || m_abortRequested.load()) { + return fail(ErrorCode::Cancelled, "aborted while draining the transport"); + } + if (!m_transport->read(drainTimeout, cancel)) { + break; + } + } + m_transport->clear(); + return {}; + } + std::this_thread::sleep_for(std::chrono::milliseconds{200}); + } + return fail(ErrorCode::Timeout, + "the instrument did not start answering again within " + + std::to_string(limit.count()) + " ms"); +} + +Status UnitrendFscanDriver::configureSweep(const SweepParams& requested) +{ + if (!isOpen()) { + return fail(ErrorCode::NotConnected, m_info.name); + } + if (auto status = m_capabilities.validate(requested); !status) { + return status; + } + m_params = requested; + m_meterMode = requested.span.width() == Hertz{0}; + return m_meterMode ? configureMeter(requested) : configureScan(requested); +} + +Status UnitrendFscanDriver::configureScan(const SweepParams& params) +{ + // Everything is set up while the instrument is stopped: a scan in progress + // discards what is sent to it. + if (auto status = send(":INITiate:STOP"); !status) { + return status; + } + if (auto status = sendValue(":INITiate2:CONTinuous", "OFF"); !status) { + return status; + } + // Only one range is measured at a time, so the other nine are switched off + // -- an enabled leftover would be scanned too and its points appended to + // the trace, silently widening the span the caller asked for. + for (int slot = 1; slot <= ScanSlots; ++slot) { + if (slot == ScanSlot) { + continue; + } + const auto node = ":SENSe:FSCan:SCAN" + std::to_string(slot) + ":STATe"; + if (auto status = sendValue(node, "OFF"); !status) { + return status; + } + } + + const std::vector> steps{ + {scanNode(":STARt"), scpi::formatHertz(params.span.start)}, + {scanNode(":STOP"), scpi::formatHertz(params.span.stop)}, + {scanNode(":POINts"), std::to_string(params.points)}, + {scanNode(":POWer:GAIN:STATe"), params.preamp ? "ON" : "OFF"}, + {":SENSe:FSCan:DETector:TRACe1", detectorKeyword(params.detector)}, + {":SENSe:FSCan:SEQuence", "SCAN"}, + }; + for (const auto& [command, value] : steps) { + if (auto status = sendValue(command, value); !status) { + return status; + } + } + + if (params.rbw > Hertz{0}) { + if (auto status = sendValue(scanNode(":BANDwidth:RESolution:AUTO"), "OFF"); !status) { + return status; + } + if (auto status = + sendValue(scanNode(":BANDwidth:RESolution"), scpi::formatHertz(params.rbw)); + !status) + { + return status; + } + } else if (auto status = sendValue(scanNode(":BANDwidth:RESolution:AUTO"), "ON"); !status) { + return status; + } + + // Always written, never inherited -- see attenuationDb. The scan list takes + // an even value up to 50 dB. + if (auto status = sendValue(scanNode(":INPut:ATTenuation"), + std::to_string(attenuationDb(params, 50, true))); + !status) + { + return status; + } + + if (params.sweepTime.count() > 0) { + if (auto status = sendValue(scanNode(":TIME:AUTO"), "OFF"); !status) { + return status; + } + if (auto status = sendValue(scanNode(":TIME"), seconds(params.sweepTime)); !status) { + return status; + } + } + + // Enabled last: the range is only accepted once its bounds make sense, and + // enabling it before they are set leaves it switched off again. + return sendValue(scanNode(":STATe"), "ON"); +} + +Status UnitrendFscanDriver::configureMeter(const SweepParams& params) +{ + if (auto status = send(":INITiate:STOP"); !status) { + return status; + } + const auto detector = detectorKeyword(params.detector); + const std::vector> steps{ + {":SENSe:FREQuency:CENTer", scpi::formatHertz(params.span.start)}, + {":SENSe:METer" + std::to_string(MeterSlot) + ":DETector", detector}, + {":DISPlay:METer" + std::to_string(MeterSlot) + ":STATe", "ON"}, + }; + for (const auto& [command, value] : steps) { + if (auto status = sendValue(command, value); !status) { + return status; + } + } + // The other two meters are switched off so the reading this driver takes + // cannot be confused with a leftover from an earlier configuration. + for (int slot = 1; slot <= 3; ++slot) { + if (slot == MeterSlot) { + continue; + } + if (auto status = sendValue(":DISPlay:METer" + std::to_string(slot) + ":STATe", "OFF"); + !status) + { + return status; + } + } + // The engine carries a Phase 2 dwell in sweepTime -- there is no separate + // dwell field on SweepParams -- and for the meter that is exactly what it + // means: how long the detector integrates before the reading is taken. + // The meter has its own front end, set separately from the scan list's and + // over the slightly wider 0 to 51 dB with no even-value restriction. + if (auto status = sendValue(":SENSe:POWer:RF:ATTenuation", + std::to_string(attenuationDb(params, 51, false))); + !status) + { + return status; + } + if (auto status = sendValue(":SENSe:POWer:RF:GAIN", params.preamp ? "ON" : "OFF"); !status) { + return status; + } + if (params.sweepTime.count() > 0) { + if (auto status = sendValue(":SENSe:METer:DETector:DWELl", seconds(params.sweepTime)); + !status) + { + return status; + } + } + return {}; +} + +Status UnitrendFscanDriver::armAndTrigger(const CancelToken& cancel) +{ + if (!isOpen()) { + return fail(ErrorCode::NotConnected, m_info.name); + } + m_abortRequested.store(false); + + if (m_meterMode) { + // Clear the peak hold first: it is what the reading is taken from, and + // an unreset one would report the previous point's level at this one. + if (auto status = send(":SENSe:METer:PHOLd:RESet"); !status) { + return status; + } + if (auto status = sendValue(":INITiate1:CONTinuous", "ON"); !status) { + return status; + } + // The meter integrates for the dwell; there is nothing to poll, so the + // wait is the dwell itself, sliced so the token still takes effect. + const auto dwell = + m_params.sweepTime.count() > 0 ? m_params.sweepTime : std::chrono::milliseconds{1000}; + const auto deadline = std::chrono::steady_clock::now() + dwell; + while (std::chrono::steady_clock::now() < deadline) { + if (cancel.isCancelled() || m_abortRequested.load()) { + return fail(ErrorCode::Cancelled, "dwell aborted"); + } + std::this_thread::sleep_for(std::chrono::milliseconds{20}); + } + return {}; + } + + if (auto status = send(":INITiate2:IMMediate"); !status) { + return status; + } + // A scan can take a long time at a narrow bandwidth over a wide span, and + // the instrument is mute for all of it, so the budget is generous: the + // engine's own timeout is what bounds a run that is genuinely stuck. + const auto budget = m_params.sweepTime.count() > 0 + ? m_params.sweepTime + std::chrono::minutes{5} + : std::chrono::minutes{5}; + if (auto status = waitUntilResponsive(cancel, budget); !status) { + return status; + } + if (m_abortRequested.load()) { + return fail(ErrorCode::Cancelled, "scan aborted"); + } + return {}; +} + +Result UnitrendFscanDriver::fetchTrace(const CancelToken& cancel) +{ + if (!isOpen()) { + return fail(ErrorCode::NotConnected, m_info.name); + } + auto trace = m_meterMode ? fetchMeterReading(cancel) : fetchScanTrace(cancel); + if (!trace) { + return trace; + } + trace->unit = m_capabilities.nativeUnit; + trace->detector = m_params.detector; + trace->params = m_params; + trace->acquiredAt = std::chrono::system_clock::now(); + if (auto identity = identify()) { + trace->source = *identity; + } + return trace; +} + +Result UnitrendFscanDriver::fetchScanTrace(const CancelToken& cancel) +{ + if (auto status = sendValue(":FORMat:TRACe:DATA", "ASCii"); !status) { + return std::unexpected(status.error()); + } + auto response = query(":TRACe:DATA? TRACE1", cancel); + if (!response) { + return std::unexpected(response.error()); + } + auto values = scpi::parseAsciiTrace(*response); + if (!values) { + return std::unexpected(values.error()); + } + if (values->empty()) { + return fail(ErrorCode::ProtocolViolation, "the instrument returned an empty scan trace"); + } + + Trace trace; + trace.axis = FrequencyAxis::linear(m_params.span, static_cast(values->size())); + trace.amplitudes = std::move(*values); + return trace; +} + +Result UnitrendFscanDriver::fetchMeterReading(const CancelToken& cancel) +{ + // The meter answers "-inf" until its detector has produced a first result, + // and how long that takes is a property of the detector rather than of the + // dwell: peak is ready within about a second of the peak hold being reset, + // quasi-peak needs about two and a half. Reading once when the dwell timer + // expires therefore returns -inf for exactly the detectors this driver + // exists to provide, so the reading is polled until the instrument has one. + // + // The value taken is the peak hold, which accumulates from the reset in + // armAndTrigger, so waiting longer can only extend the window the maximum + // was taken over -- never shorten it below the dwell that was asked for. + const auto deadline = std::chrono::steady_clock::now() + m_meterSettleBudget; + for (;;) { + if (cancel.isCancelled() || m_abortRequested.load()) { + return fail(ErrorCode::Cancelled, "dwell aborted"); + } + auto response = query(":CALCulate:METer:POWer:PEAK?", cancel); + if (!response) { + return std::unexpected(response.error()); + } + auto values = scpi::parseAsciiTrace(*response); + if (!values || values->empty()) { + return fail(ErrorCode::ProtocolViolation, + "could not read the meter: '" + *response + "'"); + } + + // One field per meter; only the first is enabled. A disabled meter + // reports 9.91e+37, the SCPI "not a number", and one that has not + // measured yet reports -inf. Neither is a level, and neither may reach + // a report as though it were. + const double reading = values->front(); + if (std::isfinite(reading) && reading < NotMeasuring) { + // The meter always answers in dBm, whatever :UNIT:POWer says -- + // unlike the scan trace, which honours it. Converting here keeps + // every trace this driver returns in the unit capabilities() + // advertises. + const auto converted = + convertAmplitude(reading, AmplitudeUnit::dBm, m_capabilities.nativeUnit); + if (!converted) { + return fail(ErrorCode::InvalidConfiguration, + "the meter reads dBm, which does not convert to the unit this " + "driver advertises"); + } + Trace trace; + trace.axis = FrequencyAxis::linear(m_params.span, 1); + trace.amplitudes = {*converted}; + return trace; + } + + if (std::chrono::steady_clock::now() >= deadline) { + return fail(ErrorCode::Timeout, + "the meter produced no reading within " + + std::to_string(m_meterSettleBudget.count()) + + " ms of the dwell ending; it may not have been measuring"); + } + std::this_thread::sleep_for(std::chrono::milliseconds{200}); + } +} + +void UnitrendFscanDriver::abort() +{ + m_abortRequested.store(true); + m_abortToken.cancel(); + if (!m_transport) { + return; + } + // Best effort, and worth being honest about how weak it is: a scan in + // progress makes the instrument deaf as well as mute, so this command is + // discarded unless it happens to land between scans. The instrument + // finishes the scan it started regardless -- an aborted run can leave it + // sweeping for as long as that takes, and it answers nothing until it is + // done. The flags above are what actually stops this driver; the command is + // here for the case where it can still be heard. + if (auto status = m_transport->write(":INITiate:STOP"); !status) { + qCDebug(lcDriver) << "abort: could not ask the instrument to stop:" + << QString::fromStdString(status.error().message()); + } + m_transport->clear(); +} + +std::vector UnitrendFscanDriver::lastErrors() +{ + std::vector errors; + if (!isOpen()) { + return errors; + } + for (int i = 0; i < 16; ++i) { + auto response = query(":SYSTem:ERRor?", m_abortToken); + if (!response) { + break; + } + auto entry = scpi::parseErrorQueueEntry(*response); + if (!entry || entry->first == 0) { + break; + } + errors.push_back(InstrumentError{entry->first, entry->second}); + } + if (!errors.empty()) { + qCWarning(lcDriver) << QString::fromStdString(m_info.name) << "reported" << errors.size() + << "error(s)"; + } + return errors; +} + +void UnitrendFscanDriver::setTimeout(std::chrono::milliseconds timeout) +{ + m_timeout = timeout; +} + +DriverPtr makeUnitrendFscanDriver() +{ + const auto profile = unitrendEmiProfile(); + const DriverInfo info{ + .id = "unitrend.uts3000t.emi", + .name = profile.name, + .vendor = "UNI-T", + .version = "1.0", + .origin = "built-in", + .supportedTransports = {TransportKind::Tcp, TransportKind::Vxi11, TransportKind::UsbTmc}}; + return std::make_shared(info, profile.capabilities); +} + +} // namespace peakemi::drivers diff --git a/tests/fixtures/ScriptedTransport.h b/tests/fixtures/ScriptedTransport.h index 0c66285..110f2ec 100644 --- a/tests/fixtures/ScriptedTransport.h +++ b/tests/fixtures/ScriptedTransport.h @@ -42,6 +42,18 @@ class ScriptedTransport final : public ITransport }); } + /// Position of the first command with @p prefix in the transcript, or -1. + /// Lets a test assert the order of two settings, which matters when one is + /// interpreted in terms of the other. + [[nodiscard]] int indexOfCommandStartingWith(std::string_view prefix) const + { + const auto found = + std::find_if(m_commands.begin(), m_commands.end(), [prefix](const std::string& sent) { + return sent.rfind(prefix, 0) == 0; + }); + return found == m_commands.end() ? -1 : static_cast(found - m_commands.begin()); + } + [[nodiscard]] Status open() override { m_open = true; @@ -76,6 +88,10 @@ class ScriptedTransport final : public ITransport if (found == m_responses.end()) { return fail(ErrorCode::Timeout, "no scripted response for '" + m_pending + "'"); } + // One reply per query, as an instrument does: a second read with no + // query in between finds nothing. Drivers that drain the line until a + // read fails would otherwise never see it run dry. + m_pending.clear(); return found->second; } diff --git a/tests/unit/DriversTest.cpp b/tests/unit/DriversTest.cpp index d2b5b4e..1d524d2 100644 --- a/tests/unit/DriversTest.cpp +++ b/tests/unit/DriversTest.cpp @@ -5,10 +5,13 @@ #include #include #include +#include #include +#include #include +#include using namespace peakemi; @@ -33,6 +36,17 @@ private slots: void profilesFallBackToTheFamily(); void driverNarrowsItsCapabilitiesAfterIdentifying(); void siglentDoesNotSendAFixedPointCount(); + void unitrendProfileMatchesTheInstrumentOnTheBench(); + void unitrendRefusesWhatItCannotMeasure(); + void unitrendSendsTheUnitBeforeTheReferenceLevel(); + void unitrendDriverCompletesASweep(); + void fscanProfileOffersWhatTheEmiOptionAdds(); + void fscanDriverEntersEmiModeAndRefusesIfItCannot(); + void fscanDriverConfiguresOneScanRange(); + void fscanDriverAlwaysSetsTheAttenuation(); + void fscanDriverDwellsWithTheMeterOnZeroSpan(); + void fscanDriverWaitsOutAMeterThatHasNotSettled(); + void fscanDriverRefusesAMeterThatReportedNothing(); }; void DriversTest::simulatedDriverIsDeterministic() @@ -265,13 +279,21 @@ void DriversTest::profilesCoverTheSupportedModels() for (const auto& profile : profiles) { QVERIFY2(profile.capabilities.range.isValid(), profile.name.c_str()); QVERIFY2(!profile.models.empty(), profile.name.c_str()); - QVERIFY2(profile.capabilities.supports(Detector::QuasiPeak), profile.name.c_str()); QVERIFY2(profile.capabilities.nativeUnit == AmplitudeUnit::dBuV, profile.name.c_str()); - - // The bandwidths CISPR 16-1-1 mandates for the bands the instrument - // covers have to be available exactly, or a Phase 2 dwell would be - // measured at the wrong bandwidth and the numbers would not mean what - // the report says they mean. + QVERIFY2(profile.capabilities.minimumPoints <= profile.capabilities.maximumPoints, + profile.name.c_str()); + QVERIFY2(!profile.capabilities.detectors.empty(), profile.name.c_str()); + QVERIFY2(profile.capabilities.supports(Detector::Peak), profile.name.c_str()); + + // A profile that offers the quasi-peak detector is claiming the + // instrument can make a CISPR-conformant Phase 2 dwell, so it must also + // have the bandwidths CISPR 16-1-1 mandates for the bands it covers -- + // exactly, not merely nearby. Claiming one without the other is the + // combination that produces a measurement labelled quasi-peak but taken + // at whatever bandwidth happened to be closest. + if (!profile.capabilities.supports(Detector::QuasiPeak)) { + continue; + } for (const auto& band : cisprBands()) { if (band.range.start >= profile.capabilities.range.stop) { continue; @@ -376,5 +398,398 @@ void DriversTest::siglentDoesNotSendAFixedPointCount() transport->sawCommandStartingWith(":SENSe:FREQuency:STARt")); } +void DriversTest::unitrendProfileMatchesTheInstrumentOnTheBench() +{ + // *IDN? of the reference unit: "UNI-TREND,UTS3032T+,ASA3726210001,V1.04.0059". + // The vendor field is the long form, the model carries a '+', and both have + // to reach the profile for the driver to narrow itself correctly. + const auto profile = drivers::profileFor("UNI-TREND", "UTS3032T+"); + QVERIFY(profile.has_value()); + QCOMPARE(profile->name, std::string{"UNI-T UTS3032T+"}); + QCOMPARE(profile->capabilities.range.stop, gigahertz(3.2)); + QCOMPARE(profile->capabilities.minimumPoints, 11); + QCOMPARE(profile->capabilities.maximumPoints, 10001); + QVERIFY(profile->capabilities.preamp); + + // Measured on the instrument: no quasi-peak and no RMS, both answered with + // -224 "Illegal parameter value". + QVERIFY(profile->capabilities.supports(Detector::Peak)); + QVERIFY(profile->capabilities.supports(Detector::Average)); + QVERIFY(profile->capabilities.supports(Detector::Sample)); + QVERIFY(!profile->capabilities.supports(Detector::QuasiPeak)); + QVERIFY(!profile->capabilities.supports(Detector::Rms)); + + // A plain 1-3-10 ladder that stops at 1 MHz, with none of the three CISPR + // bandwidths in it. + const auto& bandwidths = profile->capabilities.resolutionBandwidths; + QCOMPARE(bandwidths.front(), hertz(1)); + QCOMPARE(bandwidths.back(), megahertz(1)); + for (const auto missing : {hertz(200), kilohertz(9), kilohertz(120)}) { + QVERIFY2(std::find(bandwidths.begin(), bandwidths.end(), missing) == bandwidths.end(), + std::to_string(missing.value()).c_str()); + } + + // An unlisted model of the series keeps the family defaults. + QVERIFY(!drivers::profileFor("UNI-TREND", "UTS3021T").has_value()); + const auto family = drivers::familyProfile("UNI-TREND"); + QCOMPARE(family.name, std::string{"UNI-T UTS3000T"}); + QCOMPARE(family.capabilities.range.stop, gigahertz(3.2)); +} + +void DriversTest::unitrendRefusesWhatItCannotMeasure() +{ + auto transport = std::make_shared(); + transport->setResponse("*IDN?", "UNI-TREND,UTS3032T+,ASA3726210001,V1.04.0059"); + + auto driver = drivers::makeUnitrendUtsDriver(); + QVERIFY(driver->open(transport).has_value()); + QCOMPARE(driver->identify()->model, std::string{"UTS3032T+"}); + + SweepParams params; + params.span = FrequencyRange{megahertz(30), megahertz(230)}; + params.points = 1001; + params.refLevel = decibel(107.0); + + // The instrument tunes 100 kHz when asked for the CISPR 120 kHz and reports + // no error, so the refusal has to come from here or the run would carry a + // bandwidth the report cannot justify. + params.rbw = kilohertz(120); + auto rejected = driver->configureSweep(params); + QVERIFY(!rejected.has_value()); + QCOMPARE(rejected.error().code, ErrorCode::UnsupportedSetting); + QVERIFY(rejected.error().detail.find("resolution bandwidth") != std::string::npos); + + // Same for the detector the instrument does not have. + params.rbw = kilohertz(100); + params.detector = Detector::QuasiPeak; + rejected = driver->configureSweep(params); + QVERIFY(!rejected.has_value()); + QCOMPARE(rejected.error().code, ErrorCode::UnsupportedSetting); + QVERIFY(rejected.error().detail.find("detector") != std::string::npos); + + // And for a span past the 3.2 GHz ceiling, which the instrument would + // silently clamp rather than refuse. + params.detector = Detector::Peak; + params.span = FrequencyRange{megahertz(30), gigahertz(4.0)}; + rejected = driver->configureSweep(params); + QVERIFY(!rejected.has_value()); + QCOMPARE(rejected.error().code, ErrorCode::UnsupportedSetting); +} + +void DriversTest::unitrendSendsTheUnitBeforeTheReferenceLevel() +{ + auto transport = std::make_shared(); + transport->setResponse("*IDN?", "UNI-TREND,UTS3032T+,ASA3726210001,V1.04.0059"); + + auto driver = drivers::makeUnitrendUtsDriver(); + QVERIFY(driver->open(transport).has_value()); + + SweepParams params; + params.span = FrequencyRange{megahertz(30), megahertz(230)}; + params.points = 1001; + params.rbw = kilohertz(100); + params.refLevel = decibel(107.0); + QVERIFY(driver->configureSweep(params).has_value()); + + // This instrument reads the reference level in whatever unit is selected at + // the time, so 107 arriving before ":UNIT:POWer DBUV" would be taken as + // 107 dBm and clamped to the top of the scale -- a wrong reference level + // with no error to show for it. + const auto unitIndex = transport->indexOfCommandStartingWith(":UNIT:POWer"); + const auto refLevelIndex = + transport->indexOfCommandStartingWith(":DISPlay:WINDow:TRACe:Y:SCALe:RLEVel"); + QVERIFY(unitIndex >= 0); + QVERIFY(refLevelIndex >= 0); + QVERIFY2(unitIndex < refLevelIndex, "the amplitude unit must precede the reference level"); + + // The point count is writable here, unlike on the Siglent. + QVERIFY(transport->sawCommandStartingWith(":SENSe:SWEep:POINts 1001")); +} + +void DriversTest::unitrendDriverCompletesASweep() +{ + auto transport = std::make_shared(); + transport->setResponse("*IDN?", "UNI-TREND,UTS3032T+,ASA3726210001,V1.04.0059"); + transport->setResponse("*OPC?", "1"); + transport->setResponse(":TRACe:DATA? TRACE1", "25.9,23.6,21.3,44.5"); + + auto driver = drivers::makeUnitrendUtsDriver(); + QVERIFY(driver->open(transport).has_value()); + + SweepParams params; + params.span = FrequencyRange{megahertz(88), megahertz(108)}; + params.points = 1001; + params.rbw = kilohertz(100); + params.refLevel = decibel(107.0); + QVERIFY(driver->configureSweep(params).has_value()); + + const CancelToken cancel; + QVERIFY(driver->armAndTrigger(cancel).has_value()); + const auto trace = driver->fetchTrace(cancel); + const auto reason = test::errorText(trace); + QVERIFY2(trace.has_value(), reason.constData()); + QCOMPARE(trace->amplitudes, (std::vector{25.9, 23.6, 21.3, 44.5})); + QCOMPARE(trace->unit, AmplitudeUnit::dBuV); + QCOMPARE(trace->source.manufacturer, std::string{"UNI-TREND"}); + QCOMPARE(trace->source.model, std::string{"UTS3032T+"}); + QCOMPARE(trace->axis.start, megahertz(88)); + QCOMPARE(trace->axis.stop, megahertz(108)); +} + +namespace { + +/// A scripted instrument that behaves like the EMI personality: it names +/// itself, reports the mode, and answers the error queue. +[[nodiscard]] std::shared_ptr emiTransport() +{ + auto transport = std::make_shared(); + transport->setResponse("*IDN?", "UNI-TREND,UTS3032T+,ASA3726210001,V1.04.0059"); + transport->setResponse(":INSTrument:SELect?", "EMI"); + transport->setResponse(":SYSTem:ERRor?", "0,\"No error\""); + return transport; +} + +} // namespace + +void DriversTest::fscanProfileOffersWhatTheEmiOptionAdds() +{ + const auto profile = drivers::unitrendEmiProfile(); + const auto& capabilities = profile.capabilities; + + // The whole point of this driver: the detectors the SA mode does not have. + QVERIFY(capabilities.supports(Detector::QuasiPeak)); + QVERIFY(capabilities.supports(Detector::Peak)); + QVERIFY(capabilities.supports(Detector::Average)); + QVERIFY(!capabilities.supports(Detector::Rms)); + QVERIFY(!capabilities.supports(Detector::Sample)); + + // Exactly the CISPR 16-1-1 bandwidths, and exactly those: this mode has no + // 1-3-10 ladder at all, so an instrument-shaped bandwidth like 100 kHz is + // refused here rather than snapped to 120 kHz by the instrument. + QCOMPARE(capabilities.resolutionBandwidths, + (std::vector{hertz(200), kilohertz(9), kilohertz(120), megahertz(1)})); + for (const auto& band : cisprBands()) { + if (band.range.start >= capabilities.range.stop) { + continue; + } + QCOMPARE(capabilities.nearestResolutionBandwidth(band.resolutionBandwidth), + band.resolutionBandwidth); + } + + // "Average" has to reach the CISPR average detector, not the plain one: + // the (AV) limit lines are written for CAVerage. + QCOMPARE(drivers::UnitrendFscanDriver::detectorKeyword(Detector::Average), + std::string{"CAVerage"}); + QCOMPARE(drivers::UnitrendFscanDriver::detectorKeyword(Detector::QuasiPeak), + std::string{"QPEak"}); + QVERIFY(drivers::UnitrendFscanDriver::detectorKeyword(Detector::Rms).empty()); +} + +void DriversTest::fscanDriverEntersEmiModeAndRefusesIfItCannot() +{ + auto transport = emiTransport(); + auto driver = drivers::makeUnitrendFscanDriver(); + QVERIFY(driver->open(transport).has_value()); + QVERIFY(transport->sawCommandStartingWith(":INSTrument:SELect EMI")); + // The scan trace honours the unit; asking for dBuV is what keeps the + // amplitudes in the unit the limit lines are written in. + QVERIFY(transport->sawCommandStartingWith(":UNIT:POWer DBUV")); + + // Closing must leave the instrument stopped, not scanning. A continuous EMI + // scan never yields the bus, so an instrument left running that way cannot + // be reached again over the network at all. + driver->close(); + QVERIFY(transport->sawCommandStartingWith(":INITiate:STOP")); + QVERIFY(transport->sawCommandStartingWith(":INITiate2:CONTinuous OFF")); + QVERIFY(!transport->sawCommandStartingWith(":INITiate2:CONTinuous ON")); + + // An instrument without the EMI option stays in SA, and that has to be a + // refusal with a reason rather than a run measured in the wrong mode. + auto plain = std::make_shared(); + plain->setResponse("*IDN?", "UNI-TREND,UTS3032T+,ASA3726210001,V1.04.0059"); + plain->setResponse(":INSTrument:SELect?", "SA"); + auto refused = drivers::makeUnitrendFscanDriver(); + const auto status = refused->open(plain); + QVERIFY(!status.has_value()); + QCOMPARE(status.error().code, ErrorCode::UnsupportedSetting); + QVERIFY(status.error().detail.find("EMI") != std::string::npos); +} + +void DriversTest::fscanDriverConfiguresOneScanRange() +{ + auto transport = emiTransport(); + auto driver = drivers::makeUnitrendFscanDriver(); + QVERIFY(driver->open(transport).has_value()); + + SweepParams params; + params.span = FrequencyRange{megahertz(30), megahertz(230)}; + params.points = 1001; + params.rbw = kilohertz(120); + params.detector = Detector::QuasiPeak; + QVERIFY(driver->configureSweep(params).has_value()); + + QVERIFY(transport->sawCommandStartingWith(":SENSe:FSCan:SCAN1:STARt 30000000")); + QVERIFY(transport->sawCommandStartingWith(":SENSe:FSCan:SCAN1:STOP 230000000")); + QVERIFY(transport->sawCommandStartingWith(":SENSe:FSCan:SCAN1:POINts 1001")); + QVERIFY(transport->sawCommandStartingWith(":SENSe:FSCan:SCAN1:BANDwidth:RESolution 120000")); + QVERIFY(transport->sawCommandStartingWith(":SENSe:FSCan:DETector:TRACe1 QPEak")); + + // Every other slot is switched off: one left enabled would be scanned too + // and its points appended, widening the span without saying so. + for (int slot = 2; slot <= 10; ++slot) { + const auto command = ":SENSe:FSCan:SCAN" + std::to_string(slot) + ":STATe OFF"; + QVERIFY2(transport->sawCommandStartingWith(command), command.c_str()); + } + + // The range is enabled last -- enabling it before its bounds are set leaves + // it switched off again on the real instrument. + const auto enabled = transport->indexOfCommandStartingWith(":SENSe:FSCan:SCAN1:STATe ON"); + const auto stop = transport->indexOfCommandStartingWith(":SENSe:FSCan:SCAN1:STOP"); + QVERIFY(enabled >= 0); + QVERIFY2(stop < enabled, "the scan range must be enabled after its bounds are set"); + + // A bandwidth this mode does not have is refused rather than snapped. + params.rbw = kilohertz(100); + const auto rejected = driver->configureSweep(params); + QVERIFY(!rejected.has_value()); + QCOMPARE(rejected.error().code, ErrorCode::UnsupportedSetting); +} + +void DriversTest::fscanDriverAlwaysSetsTheAttenuation() +{ + // The EMI mode has no auto-attenuation. Leaving the instrument's previous + // value in place is what made an open input read forty decibels high, so + // the driver must write one every time, including when the caller asked + // for "automatic". + auto transport = emiTransport(); + auto driver = drivers::makeUnitrendFscanDriver(); + QVERIFY(driver->open(transport).has_value()); + + SweepParams params; + params.span = FrequencyRange{megahertz(30), megahertz(230)}; + params.points = 1001; + params.rbw = kilohertz(120); + params.automaticAttenuation = true; + QVERIFY(driver->configureSweep(params).has_value()); + QVERIFY(transport->sawCommandStartingWith(":SENSe:FSCan:SCAN1:INPut:ATTenuation 10")); + + // An explicit odd value is rounded to the even step the scan list documents, + // here rather than silently by the instrument. + auto second = emiTransport(); + auto rounding = drivers::makeUnitrendFscanDriver(); + QVERIFY(rounding->open(second).has_value()); + params.automaticAttenuation = false; + params.attenuation = decibel(11.0); + QVERIFY(rounding->configureSweep(params).has_value()); + QVERIFY(second->sawCommandStartingWith(":SENSe:FSCan:SCAN1:INPut:ATTenuation 10")); +} + +void DriversTest::fscanDriverDwellsWithTheMeterOnZeroSpan() +{ + auto transport = emiTransport(); + // Three meters answer; only the first is enabled, the others report the + // "not measuring" sentinel. + transport->setResponse(":CALCulate:METer:POWer:PEAK?", + "-8.233855e+01,9.910000e+37,9.910000e+37"); + auto driver = drivers::makeUnitrendFscanDriver(); + QVERIFY(driver->open(transport).has_value()); + + SweepParams params; + params.span = FrequencyRange{megahertz(98), megahertz(98)}; // zero span: a dwell + // The point count belongs to the scan list and means nothing to the meter, + // but it is still validated, and the engine coerces it up to the minimum + // before the driver ever sees it -- so this is what actually arrives. + params.points = 11; + params.rbw = kilohertz(120); + params.detector = Detector::QuasiPeak; + params.sweepTime = std::chrono::milliseconds{50}; // the engine carries the dwell here + QVERIFY(driver->configureSweep(params).has_value()); + + QVERIFY(transport->sawCommandStartingWith(":SENSe:FREQuency:CENTer 98000000")); + QVERIFY(transport->sawCommandStartingWith(":SENSe:METer1:DETector QPEak")); + QVERIFY(transport->sawCommandStartingWith(":SENSe:METer:DETector:DWELl")); + QVERIFY(transport->sawCommandStartingWith(":DISPlay:METer1:STATe ON")); + // Nothing from the scan list: a dwell is a different instrument function. + QVERIFY(!transport->sawCommandStartingWith(":SENSe:FSCan:SCAN1:STARt")); + + const CancelToken cancel; + QVERIFY(driver->armAndTrigger(cancel).has_value()); + // The peak hold is what the reading is taken from, so it has to be cleared + // or this point reports the previous one's level. + QVERIFY(transport->sawCommandStartingWith(":SENSe:METer:PHOLd:RESet")); + + const auto trace = driver->fetchTrace(cancel); + const auto reason = test::errorText(trace); + QVERIFY2(trace.has_value(), reason.constData()); + QCOMPARE(trace->size(), 1); + // The meter answers in dBm whatever :UNIT:POWer says, so -82.34 dBm has to + // come back as the dBuV the driver advertises: +107 dB in 50 ohms. + QCOMPARE(trace->unit, AmplitudeUnit::dBuV); + QVERIFY(std::abs(trace->amplitudes.front() - 24.66) < 0.02); + QCOMPARE(trace->detector, Detector::QuasiPeak); +} + +void DriversTest::fscanDriverWaitsOutAMeterThatHasNotSettled() +{ + auto transport = emiTransport(); + // What the instrument really answers for the first second or two after the + // peak hold is reset, before the detector has formed a result. + transport->setResponse(":CALCulate:METer:POWer:PEAK?", "-inf,9.910000e+37,9.910000e+37"); + auto driver = drivers::makeUnitrendFscanDriver(); + QVERIFY(driver->open(transport).has_value()); + auto* fscan = static_cast(driver.get()); + fscan->setMeterSettleBudget(std::chrono::seconds{5}); + + SweepParams params; + params.span = FrequencyRange{megahertz(98), megahertz(98)}; + params.points = 11; + params.detector = Detector::QuasiPeak; + params.sweepTime = std::chrono::milliseconds{20}; + QVERIFY(driver->configureSweep(params).has_value()); + + const CancelToken cancel; + QVERIFY(driver->armAndTrigger(cancel).has_value()); + + // Once the detector settles the reading is taken -- -inf must never be + // mistaken for a level, and the quasi-peak detector reports it for about + // two seconds after every reset. + transport->setResponse(":CALCulate:METer:POWer:PEAK?", + "-8.233855e+01,9.910000e+37,9.910000e+37"); + const auto trace = driver->fetchTrace(cancel); + const auto reason = test::errorText(trace); + QVERIFY2(trace.has_value(), reason.constData()); + QVERIFY(std::abs(trace->amplitudes.front() - 24.66) < 0.02); +} + +void DriversTest::fscanDriverRefusesAMeterThatReportedNothing() +{ + auto transport = emiTransport(); + // Every meter disabled: 9.91e+37 is the SCPI "not a number", and it must + // never reach a report as though it were a level. + transport->setResponse(":CALCulate:METer:POWer:PEAK?", + "9.910000e+37,9.910000e+37,9.910000e+37"); + auto driver = drivers::makeUnitrendFscanDriver(); + QVERIFY(driver->open(transport).has_value()); + // Fail fast: the point of this test is the refusal, not the waiting. + std::static_pointer_cast(driver)->setMeterSettleBudget( + std::chrono::milliseconds{300}); + + SweepParams params; + params.span = FrequencyRange{megahertz(98), megahertz(98)}; + params.points = 11; + params.detector = Detector::Peak; + params.sweepTime = std::chrono::milliseconds{20}; + QVERIFY(driver->configureSweep(params).has_value()); + + const CancelToken cancel; + QVERIFY(driver->armAndTrigger(cancel).has_value()); + const auto trace = driver->fetchTrace(cancel); + QVERIFY(!trace.has_value()); + // A meter that never produces a value is a timeout, not a level: neither + // the 9.91e+37 sentinel nor the -inf it reports before its first result may + // ever be returned as an amplitude. + QCOMPARE(trace.error().code, ErrorCode::Timeout); +} + QTEST_APPLESS_MAIN(DriversTest) #include "DriversTest.moc" From 93dfd5e89220155cbee8ab3828e67a14b85cdbdb Mon Sep 17 00:00:00 2001 From: Bernhard Trinnes Date: Wed, 9 Sep 2026 11:06:28 +0200 Subject: [PATCH 3/3] Let a headless run choose the verification span The Phase 2 span was reachable from the run configuration in the application but not from the command line, so a pipeline was stuck with the 200 kHz default. That matters on an instrument with a fixed-tuned receiver: zero span is what routes the dwell to it and has the detector integrate at the frequency, where a swept verification span divides the dwell across its points instead. For an impulsive emission, which is what the quasi-peak detector exists to weight, those are not the same measurement. --verification-span takes the same frequency spelling as --start and --stop, and 0 is the value that matters, so it has its own test: a range check written without thinking would have rejected exactly the case the option was added for. Unset stays nullopt and cannot overwrite what a session file already said, like every other override here. --- src/cli/include/peakemi/cli/CommandLine.h | 6 ++++++ src/cli/options/CommandLine.cpp | 13 +++++++++++++ src/cli/run/HeadlessRunner.cpp | 3 +++ tests/unit/CommandLineTest.cpp | 15 ++++++++++++++- 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/cli/include/peakemi/cli/CommandLine.h b/src/cli/include/peakemi/cli/CommandLine.h index 1c5ce93..97b99a3 100644 --- a/src/cli/include/peakemi/cli/CommandLine.h +++ b/src/cli/include/peakemi/cli/CommandLine.h @@ -67,6 +67,12 @@ struct HeadlessOptions std::optional points; std::optional refLevel; std::optional verificationDetector; + /// Span of the Phase 2 verification sweep, centred on the peak. Zero means + /// zero span, which on instruments that have a fixed-tuned receiver is what + /// gets the dwell measured at the frequency rather than spread across a + /// sweep -- the difference between a quasi-peak reading that means what + /// CISPR says and one that reads low. + std::optional verificationSpan; std::optional dwell; std::optional passes; std::optional maximumPeaks; diff --git a/src/cli/options/CommandLine.cpp b/src/cli/options/CommandLine.cpp index a5ea065..76a3e61 100644 --- a/src/cli/options/CommandLine.cpp +++ b/src/cli/options/CommandLine.cpp @@ -31,6 +31,7 @@ constexpr auto Points = QLatin1StringView{"points"}; constexpr auto RefLevel = QLatin1StringView{"ref-level"}; constexpr auto Detector = QLatin1StringView{"detector"}; constexpr auto Dwell = QLatin1StringView{"dwell"}; +constexpr auto VerificationSpan = QLatin1StringView{"verification-span"}; constexpr auto Passes = QLatin1StringView{"passes"}; constexpr auto MaxPeaks = QLatin1StringView{"max-peaks"}; constexpr auto Margin = QLatin1StringView{"margin"}; @@ -239,6 +240,11 @@ void defineOptions(QCommandLineParser& parser) QStringLiteral("Phase 2 detector: peak, quasi-peak, average, rms or sample."), QStringLiteral("name")}, {name::Dwell, QStringLiteral("Phase 2 dwell time, e.g. 1s."), QStringLiteral("duration")}, + {name::VerificationSpan, + QStringLiteral("Span of the Phase 2 sweep, centred on the peak, e.g. 200k. Use 0 for " + "zero span, which dwells at the frequency instead of sweeping across " + "it -- what a CISPR quasi-peak measurement needs."), + QStringLiteral("frequency")}, {name::Passes, QStringLiteral("Max-hold passes over the whole loop."), QStringLiteral("count")}, @@ -380,6 +386,13 @@ Result parseCommandLine(const QStringList& arguments) } out.stop = *value; } + if (parser.isSet(name::VerificationSpan)) { + auto value = parseFrequency(parser.value(name::VerificationSpan)); + if (!value) { + return std::unexpected(value.error()); + } + out.verificationSpan = *value; + } if (parser.isSet(name::Dwell)) { auto value = parseDuration(parser.value(name::Dwell)); if (!value) { diff --git a/src/cli/run/HeadlessRunner.cpp b/src/cli/run/HeadlessRunner.cpp index 3b8b4b2..528a872 100644 --- a/src/cli/run/HeadlessRunner.cpp +++ b/src/cli/run/HeadlessRunner.cpp @@ -216,6 +216,9 @@ Result HeadlessRunner::buildConfiguration() if (m_options.verificationDetector) { config.verificationDetector = *m_options.verificationDetector; } + if (m_options.verificationSpan) { + config.verificationSpan = *m_options.verificationSpan; + } if (m_options.dwell) { config.dwellTime = *m_options.dwell; } diff --git a/tests/unit/CommandLineTest.cpp b/tests/unit/CommandLineTest.cpp index c43faa4..decb9f8 100644 --- a/tests/unit/CommandLineTest.cpp +++ b/tests/unit/CommandLineTest.cpp @@ -132,6 +132,7 @@ void CommandLineTest::overridesAreOnlySetWhenGiven() QVERIFY(!bare->options.stop.has_value()); QVERIFY(!bare->options.dwell.has_value()); QVERIFY(!bare->options.points.has_value()); + QVERIFY(!bare->options.verificationSpan.has_value()); const auto set = parseCommandLine(commandLine({QStringLiteral("--start"), QStringLiteral("150k"), @@ -142,7 +143,9 @@ void CommandLineTest::overridesAreOnlySetWhenGiven() QStringLiteral("--points"), QStringLiteral("2001"), QStringLiteral("--detector"), - QStringLiteral("average")})); + QStringLiteral("average"), + QStringLiteral("--verification-span"), + QStringLiteral("200k")})); const auto reason = test::errorText(set); QVERIFY2(set.has_value(), reason.constData()); QCOMPARE(set->options.start.value(), kilohertz(150)); @@ -150,6 +153,16 @@ void CommandLineTest::overridesAreOnlySetWhenGiven() QCOMPARE(set->options.dwell.value(), std::chrono::milliseconds{2000}); QCOMPARE(set->options.points.value(), 2001); QCOMPARE(set->options.verificationDetector.value(), Detector::Average); + QCOMPARE(set->options.verificationSpan.value(), kilohertz(200)); + + // Zero is the value that matters and the one a range check would reject by + // accident: it selects zero span, which is what dwells at the frequency + // instead of sweeping across it. + const auto zero = + parseCommandLine(commandLine({QStringLiteral("--verification-span"), QStringLiteral("0")})); + const auto zeroReason = test::errorText(zero); + QVERIFY2(zero.has_value(), zeroReason.constData()); + QCOMPARE(zero->options.verificationSpan.value(), Hertz{0}); } void CommandLineTest::repeatableOptionsKeepTheirOrder()