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/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/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 = 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/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() 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"