// Part of BNC, a utility for retrieving decoding and // converting GNSS data streams from NTRIP broadcasters. // // Copyright (C) 2007 // German Federal Agency for Cartography and Geodesy (BKG) // http://www.bkg.bund.de // Czech Technical University Prague, Department of Geodesy // http://www.fsv.cvut.cz // // Email: euref-ip@bkg.bund.de // // This program is free software; you can redistribute it and/or // modify it under the terms of the GNU General Public License // as published by the Free Software Foundation, version 2. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program; if not, write to the Free Software // Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. /* ------------------------------------------------------------------------- * BKG NTRIP Client * ------------------------------------------------------------------------- * * Class: t_ssrQc * * Purpose: Quality control of decoded SSR streams * * Created: 05-Oct-2026 * * Changes: * * -----------------------------------------------------------------------*/ #include #include #include #include "ssrQc.h" #include "ssrRefSig.h" #include "combination/bncbiassnx.h" #include "bnccore.h" #include "bncutils.h" #include "bncconst.h" #include "bncsettings.h" #include "combination/bnccomb.h" #include "bncantex.h" using namespace std; namespace { const double MAX_CLK_JUMP = 0.5; // [m] clock change beyond its rate between consecutive epochs const double EPH_WARMUP = 120.0; // [s] no ephemeris checks at the start const double EPH_EARLY_MAX = 300.0; // [s] IOD received this much later still counted as early const double EPH_IOD_REUSE = 21600.0; // [s] an outdated data set this far from the epoch: IOD reused const unsigned EPH_KEPT = 30; // data sets kept per satellite const double CMP_SAMPL = 30.0; // [s] sampling of the stream comparison const double CMP_DELAY = 60.0; // [s] epochs compared when data this much newer arrived const double HIST_LENGTH = 300.0; // [s] corrections kept for the comparison const double DEV_MIN = 0.05; // [m] deviating satellites: RMS above max(DEV_MIN, const double DEV_FACTOR = 3.0; // DEV_FACTOR x median RMS of the system) const double ATT_ONSET = 20.0 * M_PI / 180.0; // [rad] change of the yaw difference starting a turn const double ATT_STABLE = 2.0 * M_PI / 180.0; // [rad] yaw difference stable again ... const double ATT_STABLE_T = 600.0; // [s] ... for this time: end of the turn const double ATT_REF_FROM = 900.0; // [s] reference before the turn: samples from 15 min const double ATT_REF_TO = 300.0; // to 5 min before the current epoch const double ATT_MAX_GAP = 600.0; // [s] longer gaps interrupt a turn const double ATT_MAX_TURN = 10800.0; // [s] a turn is closed after 3 h at the latest const double ATT_MIN_PRED = 0.03; // [m] smaller predicted clock changes are not assessed const double VTEC_CMP_SAMPL = 300.0; // [s] sampling of the VTEC comparison const double VTEC_CMP_DELAY = 120.0; // [s] VTEC epochs compared when data this much newer arrived const double VTEC_HIST = 3600.0; // [s] VTEC models kept for the comparison const double VTEC_GRID_LAT = 10.0; // [deg] grid of the VTEC evaluation, latitudes -80 ... 80 const double VTEC_GRID_LON = 15.0; // [deg] double tKey(const bncTime& tt) { return tt.gpsw() * 604800.0 + tt.gpssec(); } const char* TYPE_NAME[] = {"ORBIT", "CLOCK", "CODE_BIAS", "PHASE_BIAS", "VTEC"}; double median(QVector vv) { if (vv.isEmpty()) { return 0.0; } std::sort(vv.begin(), vv.end()); int nn = vv.size(); return (nn % 2) ? vv[nn/2] : 0.5 * (vv[nn/2-1] + vv[nn/2]); } bncTime currentTime() { int week = 0; double sec = 0.0; currentGPSWeeks(week, sec); return bncTime(week, sec); } // satellite list "G01 G05(12)", counts shown if not all epochs concerned QString satList(const QMap& sats, unsigned maxNum = 20) { QString str; unsigned num = 0; QMapIterator it(sats); while (it.hasNext()) { it.next(); if (++num > maxNum) { str += QString(" ... (%1 satellites)").arg(sats.size()); break; } str += " " + it.key(); if (it.value() > 0) { str += QString("(%1)").arg(it.value()); } } return str.isEmpty() ? " -" : str; } } // Constructor //////////////////////////////////////////////////////////////////////////// t_ssrQc::t_ssrQc(const QString& dirName, double interval, const QStringList& streams, const QString& reference) : _ephUser(true, EPH_KEPT) { _ephUser.useEphMounts("SSR QC"); _dirName = dirName; _reference = reference.isEmpty() ? QString("CONSENSUS") : reference; _interval = interval > 0.0 ? interval : 3600.0; // "ALL" or a list of streams; streams with a leading '!' are excluded // (from the reports and from the consensus) bool all = false; for (const QString& sta : streams) { if (sta.startsWith('!')) { _excluded.insert(sta.mid(1)); } else if (sta.toUpper() == "ALL") { all = true; } else { _streams.insert(sta); } } if (all) { _streams.clear(); } connect(this, SIGNAL(newMessage(QByteArray,bool)), BNC_CORE, SLOT(slotMessage(const QByteArray,bool))); for (const QString& err : t_ssrRefSig::init()) { emit newMessage(("SSR QC: " + err).toLatin1(), true); } // reference code biases from a Bias SINEX file (optional) _bsxRef = 0; bncSettings settings; QString bsxFile = settings.value("ssrQcBiasFile").toString(); expandEnvVar(bsxFile); if (!bsxFile.isEmpty()) { _bsxRef = new bncBiasSnx(true); if (_bsxRef->readFile(bsxFile) == success) { _bsxName = QFileInfo(bsxFile).fileName(); } else { emit newMessage(("SSR QC: wrong Bias SINEX file " + bsxFile).toLatin1(), true); delete _bsxRef; _bsxRef = 0; } } _antex = 0; QString antexFile = settings.value("ssrQcAntexFile").toString(); expandEnvVar(antexFile); if (!antexFile.isEmpty()) { _antex = new bncAntex(); if (_antex->readFile(antexFile) != success || _antex->numSatPrns() == 0) { emit newMessage(("SSR QC: wrong ANTEX file " + antexFile + " - no attitude check").toLatin1(), true); delete _antex; _antex = 0; } } connect(BNC_CORE, SIGNAL(newOrbCorrections(QList)), this, SLOT(slotNewOrbCorrections(QList)), Qt::DirectConnection); connect(BNC_CORE, SIGNAL(newClkCorrections(QList)), this, SLOT(slotNewClkCorrections(QList)), Qt::DirectConnection); connect(BNC_CORE, SIGNAL(newCodeBiases(QList)), this, SLOT(slotNewCodeBiases(QList)), Qt::DirectConnection); connect(BNC_CORE, SIGNAL(newPhaseBiases(QList)), this, SLOT(slotNewPhaseBiases(QList)), Qt::DirectConnection); connect(BNC_CORE, SIGNAL(newTec(t_vTec)), this, SLOT(slotNewTec(t_vTec)), Qt::DirectConnection); // combined corrections (stream INTERNAL) if (bncComb::exists()) { connect(BNC_CMB, SIGNAL(newOrbCorrections(QList)), this, SLOT(slotNewOrbCorrections(QList)), Qt::DirectConnection); connect(BNC_CMB, SIGNAL(newClkCorrections(QList)), this, SLOT(slotNewClkCorrections(QList)), Qt::DirectConnection); } } // Stream selected for the QC //////////////////////////////////////////////////////////////////////////// bool t_ssrQc::selected(const string& staID) const { QString sta = QString::fromStdString(staID); return !_excluded.contains(sta) && (_streams.isEmpty() || _streams.contains(sta)); } // Analysis center of a stream: the 3 characters of the AC in mountpoints // like SSRA00CNE1 or OSBC00WHU1, otherwise the stream name itself //////////////////////////////////////////////////////////////////////////// QString t_ssrQc::acOf(const QString& staID) { static const QRegularExpression re("^(?:SSR[AC]|OSB[AC]|IONO)[0-9]{2}([A-Z0-9]{3})[0-9]$"); QRegularExpressionMatch mm = re.match(staID); return mm.hasMatch() ? mm.captured(1) : staID; } // Solution of an AC: AC and the two digits of the mountpoint, e.g. GFZ00 for // SSRA00GFZ0/SSRC00GFZ1 and GFZ01 for SSRA01GFZ0; the streams of a solution // (formats, APC/CoM) have one vote in the consensus //////////////////////////////////////////////////////////////////////////// QString t_ssrQc::solOf(const QString& staID) { static const QRegularExpression re("^(?:SSR[AC]|OSB[AC]|IONO)([0-9]{2})([A-Z0-9]{3})[0-9]$"); QRegularExpressionMatch mm = re.match(staID); return mm.hasMatch() ? mm.captured(2) + mm.captured(1) : staID; } // AC of a solution (GFZ01 -> GFZ) //////////////////////////////////////////////////////////////////////////// QString t_ssrQc::acOfSol(const QString& sol) { static const QRegularExpression re("^([A-Z0-9]{3})[0-9]{2}$"); QRegularExpressionMatch mm = re.match(sol); return mm.hasMatch() ? mm.captured(1) : sol; } // Stream part of the consensus: not excluded, and not a combination product // (INTERNAL, IGS combinations), as these are derived from the other streams //////////////////////////////////////////////////////////////////////////// bool t_ssrQc::votes(const QString& staID) const { return !_excluded.contains(staID) && staID != "INTERNAL" && acOf(staID) != "IGS"; } // Daily QC file of a stream: _S_0000_01D_QC.txt //////////////////////////////////////////////////////////////////////////// ofstream& t_ssrQc::streamOut(const QString& staID, const bncTime& time) { unsigned year, month, day; time.civil_date(year, month, day); QDate date(year, month, day); QString name = QDir(_dirName).filePath(QString("%1_S_%2%3%4_01D_QC.txt").arg(staID) .arg(year).arg(date.dayOfYear(), 3, 10, QChar('0')).arg("0000")); if (_outNames.value(staID) != name) { delete _outs.value(staID, 0); bool exists = QFile::exists(name); ofstream* out = new ofstream(name.toLocal8Bit().data(), ios_base::out | ios_base::app); if (!exists) { *out << "SSR QC of stream " << staID.toStdString() << ", report interval " << _interval << " s" << endl << "Checks: epochs and update intervals, gaps (interval > 2 x max(declared, median)), latency," << " completeness w.r.t. healthy broadcast ephemerides," << endl << "IOD matching, code and phase bias signals, yaw angle, code biases of the reference signals, clock jumps > " << MAX_CLK_JUMP << " m, largest orbit corrections, VTEC models (system V)" << endl << "Reference signals of the clocks: " << t_ssrRefSig::currentString().toStdString() << endl; } _outs[staID] = out; _outNames[staID] = name; } return *_outs[staID]; } // Destructor //////////////////////////////////////////////////////////////////////////// t_ssrQc::~t_ssrQc() { QMutexLocker locker(&_mutex); writeReport(true); for (ofstream* out : _outs) { delete out; } delete _bsxRef; delete _antex; } // Key of the epoch statistics //////////////////////////////////////////////////////////////////////////// QString t_ssrQc::key(const string& staID, char sys, e_type type) { return QString("%1 %2 %3").arg(staID.c_str()).arg(sys).arg(int(type)); } // Reference signals of the clocks (as in the combination, option ssrRefSignals) //////////////////////////////////////////////////////////////////////////// bool t_ssrQc::refSignals(char sys, QString& sig1, QString& sig2, t_frequency::type& frq1, t_frequency::type& frq2) { t_ssrRefSig::t_pair pair; if (!t_ssrRefSig::get(sys, pair)) { return false; } sig1 = QString::fromStdString(pair.sig1()); sig2 = QString::fromStdString(pair.sig2()); frq1 = pair.frq1; frq2 = pair.frq2; return true; } // Start a new report interval if the epoch is beyond the current one //////////////////////////////////////////////////////////////////////////// void t_ssrQc::checkInterval(const bncTime& time) { if (!_intervalStart.valid()) { double sec = floor(time.gpssec() / _interval) * _interval; _intervalStart = bncTime(time.gpsw(), sec); } // stream comparison of the epochs already complete for all streams if (!_nextCmpEpoch.valid()) { double sec = ceil(time.gpssec() / CMP_SAMPL) * CMP_SAMPL; _nextCmpEpoch = bncTime(time.gpsw(), sec); } while (time - _nextCmpEpoch >= CMP_DELAY) { compareEpoch(_nextCmpEpoch); _nextCmpEpoch = _nextCmpEpoch + CMP_SAMPL; } // VTEC comparison if (!_vtecHist.isEmpty()) { if (!_nextVtecCmp.valid()) { double sec = ceil(time.gpssec() / VTEC_CMP_SAMPL) * VTEC_CMP_SAMPL; _nextVtecCmp = bncTime(time.gpsw(), sec); } while (time - _nextVtecCmp >= VTEC_CMP_DELAY) { compareVtec(_nextVtecCmp); _nextVtecCmp = _nextVtecCmp + VTEC_CMP_SAMPL; } } if (time - _intervalStart >= _interval) { writeReport(false); double sec = floor(time.gpssec() / _interval) * _interval; _intervalStart = bncTime(time.gpsw(), sec); } if (!_lastTime.valid() || _lastTime < time) { _lastTime = time; } } // Epoch statistics //////////////////////////////////////////////////////////////////////////// void t_ssrQc::newEpoch(const string& staID, char sys, e_type type, const bncTime& time, unsigned updateInt, const QString& prn) { QString sta = QString::fromStdString(staID); if (!_staStart.contains(sta)) { _staStart[sta] = time; } if (!_staLast.contains(sta) || _staLast[sta] < time) { _staLast[sta] = time; } t_epoStat& stat = _epoStat[key(staID, sys, type)]; if (updateInt < unsigned(ssrUpdateInt.size())) { stat.nominal = ssrUpdateInt[updateInt]; } stat.sats.insert(prn); if (!stat.last.valid() || stat.last < time) { if (stat.last.valid()) { stat.intervals.push_back(time - stat.last); } stat.last = time; stat.numEpo += 1; stat.latencies.push_back(currentTime() - time); } else if (time < stat.last) { stat.numOutOfOrder += 1; } } // Broadcast ephemeris the correction refers to //////////////////////////////////////////////////////////////////////////// void t_ssrQc::checkEph(t_satStat& stat, const QString& satKey, const QString& prnInt, unsigned iod, const bncTime& time, const string& staID) { // not before all broadcast ephemerides may have been received if (time - _staStart[QString::fromStdString(staID)] < EPH_WARMUP) { return; } if (!_ephUser.ephLast(prnInt)) { stat.noEph += 1; return; } // any data set kept, not only the last two: Galileo has a new IODnav every // 10 minutes, and ACs keep one for up to an hour t_eph* eph = _ephUser.ephWithIOD(prnInt, iod); // an IOD used again later (GLONASS tb every day, GPS IODE) is another data set if (eph && outDatedBcep(eph, time) && fabs(time - eph->TOC()) > EPH_IOD_REUSE) { eph = 0; } if (!eph) { // received later from the ephemeris stream(s)? decided in resolvePendingIod t_pendingIod& pending = _pendingIod[satKey][iod]; if (pending.num == 0) { pending.prnInt = prnInt; pending.first = currentTime(); } pending.num += 1; } else if (eph->checkState() == t_eph::unhealthy || eph->checkState() == t_eph::bad) { stat.ephUnhealthy += 1; } else if (outDatedBcep(eph, time)) { stat.ephOutdated += 1; } if (_pendingIod.contains(satKey)) { resolvePendingIod(satKey, false); } } // Corrections referring to an IOD not received before: counted as early if // the ephemeris stream(s) delivered the data set later, as unknown if not // within EPH_EARLY_MAX (or at the end of the report interval, final) //////////////////////////////////////////////////////////////////////////// void t_ssrQc::resolvePendingIod(const QString& satKey, bool final) { t_satStat& stat = _satStat[satKey]; QMap& pend = _pendingIod[satKey]; QMutableMapIterator it(pend); while (it.hasNext()) { it.next(); const t_pendingIod& pending = it.value(); t_eph* eph = _ephUser.ephWithIOD(pending.prnInt, it.key()); if (eph && eph->receptDateTime().isValid()) { bncTime recept(eph->receptDateTime().toString(Qt::ISODate).toStdString()); if (recept >= pending.first) { stat.iodEarly += pending.num; stat.maxEarly = max(stat.maxEarly, recept - pending.first); it.remove(); continue; } } if (final || currentTime() - pending.first > EPH_EARLY_MAX) { stat.iodUnknown += pending.num; it.remove(); } } if (pend.isEmpty()) { _pendingIod.remove(satKey); } } // Orbit corrections //////////////////////////////////////////////////////////////////////////// void t_ssrQc::slotNewOrbCorrections(QList orbCorrections) { QMutexLocker locker(&_mutex); for (const t_orbCorr& corr : orbCorrections) { if (!_excluded.contains(QString::fromStdString(corr._staID)) || corr._staID == _reference.toStdString()) { storeHist(&corr, 0); } if (!selected(corr._staID)) continue; QString prn = QString::fromStdString(corr._prn.toString()); checkInterval(corr._time); newEpoch(corr._staID, corr._prn.system(), ORB, corr._time, corr._updateInt, prn); t_satStat& stat = _satStat[QString::fromStdString(corr._staID) + ' ' + prn]; stat.numOrb += 1; checkEph(stat, QString::fromStdString(corr._staID) + ' ' + prn, QString::fromStdString(corr._prn.toInternalString()), corr._iod, corr._time, corr._staID); double radial = fabs(corr._xr[0]); double alongCross = max(fabs(corr._xr[1]), fabs(corr._xr[2])); stat.maxRadial = max(stat.maxRadial, radial); stat.maxAlongCross = max(stat.maxAlongCross, alongCross); } } // Clock corrections //////////////////////////////////////////////////////////////////////////// void t_ssrQc::slotNewClkCorrections(QList clkCorrections) { QMutexLocker locker(&_mutex); for (const t_clkCorr& corr : clkCorrections) { if (!_excluded.contains(QString::fromStdString(corr._staID)) || corr._staID == _reference.toStdString()) { storeHist(0, &corr); } if (!selected(corr._staID)) continue; QString prn = QString::fromStdString(corr._prn.toString()); checkInterval(corr._time); newEpoch(corr._staID, corr._prn.system(), CLK, corr._time, corr._updateInt, prn); t_satStat& stat = _satStat[QString::fromStdString(corr._staID) + ' ' + prn]; stat.numClk += 1; checkEph(stat, QString::fromStdString(corr._staID) + ' ' + prn, QString::fromStdString(corr._prn.toInternalString()), corr._iod, corr._time, corr._staID); // jump: change between consecutive epochs (same IOD) beyond the clock rate double clk = corr._dClk * t_CST::c; if (stat.lastClkTime.valid() && stat.lastIod == corr._iod) { double dt = corr._time - stat.lastClkTime; int nominal = (corr._updateInt < unsigned(ssrUpdateInt.size())) ? ssrUpdateInt[corr._updateInt] : 0; if (dt > 0.0 && dt <= max(2.0 * nominal, 60.0)) { double jump = fabs(clk - stat.lastClk - corr._dotDClk * t_CST::c * dt); stat.maxJump = max(stat.maxJump, jump); if (jump > MAX_CLK_JUMP) { stat.clkJumps += 1; } } } stat.lastClkTime = corr._time; stat.lastIod = corr._iod; stat.lastClk = clk; } } // Code biases //////////////////////////////////////////////////////////////////////////// void t_ssrQc::slotNewCodeBiases(QList codeBiases) { QMutexLocker locker(&_mutex); for (const t_satCodeBias& bias : codeBiases) { bool sel = selected(bias._staID); if (_excluded.contains(QString::fromStdString(bias._staID)) && bias._staID != _reference.toStdString()) continue; char sys = bias._prn.system(); QString prn = QString::fromStdString(bias._prn.toString()); if (sel) { checkInterval(bias._time); newEpoch(bias._staID, sys, CBIAS, bias._time, bias._updateInt, prn); } QString staSys = QString("%1 %2").arg(bias._staID.c_str()).arg(sys); QMap values; for (const t_frqCodeBias& frqBias : bias._bias) { QString sig = QString::fromStdString(frqBias._rnxType2ch); _codeSignals[staSys].insert(sig); values[sig] = frqBias._value; } if (!values.isEmpty()) { _cbLast[QString::fromStdString(bias._staID)][prn] = values; } t_satStat& stat = _satStat[QString::fromStdString(bias._staID) + ' ' + prn]; QString sig1, sig2; t_frequency::type frq1, frq2; if (refSignals(sys, sig1, sig2, frq1, frq2)) { stat.missingRefSig.clear(); if (!values.contains(sig1)) stat.missingRefSig.insert("C" + sig1); if (!values.contains(sig2)) stat.missingRefSig.insert("C" + sig2); stat.refBiasOK = stat.missingRefSig.isEmpty(); if (stat.refBiasOK) { double channel = 0.0; if (sys == 'R') { t_eph* eph = _ephUser.ephLast(QString::fromStdString(bias._prn.toInternalString())); if (!eph) { stat.refBiasOK = false; continue; } channel = eph->slotNum(); } double f1 = t_CST::freq(frq1, channel); double f2 = t_CST::freq(frq2, channel); stat.biasIF = (f1 * f1 * values[sig1] - f2 * f2 * values[sig2]) / (f1 * f1 - f2 * f2); } } } } // Phase biases //////////////////////////////////////////////////////////////////////////// void t_ssrQc::slotNewPhaseBiases(QList phaseBiases) { QMutexLocker locker(&_mutex); for (const t_satPhaseBias& bias : phaseBiases) { if (!selected(bias._staID)) continue; checkInterval(bias._time); newEpoch(bias._staID, bias._prn.system(), PBIAS, bias._time, bias._updateInt, QString::fromStdString(bias._prn.toString())); QString staSys = QString("%1 %2").arg(bias._staID.c_str()).arg(bias._prn.system()); for (const t_frqPhaseBias& frqBias : bias._bias) { _phaseSignals[staSys].insert(QString::fromStdString(frqBias._rnxType2ch)); } // Yaw angle: new RTCM-SSR has an indicator, old RTCM-SSR and IGS-SSR always // carry the field, providers without attitude information send zeros t_satStat& stat = _satStat[QString::fromStdString(bias._staID) + ' ' + QString::fromStdString(bias._prn.toString())]; stat.numPB++; bool yawProvided = (bias._ssrFormat == ssrRtcmNew) ? (bias._satYawInfoInd != 0) : (bias._yaw != 0.0 || bias._yawRate != 0.0); if (yawProvided) { stat.numYaw++; t_yawLast& yl = _yawLast[QString::fromStdString(bias._staID)][QString::fromStdString(bias._prn.toString())]; double dt = 0.0; if (bias._updateInt < unsigned(ssrUpdateInt.size())) { dt = 0.5 * ssrUpdateInt[bias._updateInt]; // reference time in the middle of the interval } yl.time = bias._time + dt; yl.yaw = bias._yaw; yl.rate = bias._yawRate; } } } // Reference DCBs (bias(base) - bias(signal)) of a satellite: median of the // other ACs (one vote per AC, own AC left out, combinations not voting) or // the selected reference stream, or from the Bias SINEX file //////////////////////////////////////////////////////////////////////////// bool t_ssrQc::refDcbs(const QString& staID, const QString& prn, const QString& base, bool fromFile, QMap& dcbs, const QString& onlyAC, QMap* numVotes) { dcbs.clear(); if (numVotes) { numVotes->clear(); } if (fromFile) { if (!_bsxRef) { return false; } char sys = prn[0].toLatin1(); t_ssrRefSig::t_pair pair; if (!t_ssrRefSig::get(sys, pair)) { return false; } // the DCBs do not depend on the coefficients (as long as they add up to 1) double f1 = t_CST::freq(pair.frq1, 0.0); double f2 = t_CST::freq(pair.frq2, 0.0); double aIF1 = f1 * f1 / (f1 * f1 - f2 * f2); double aIF2 = -f2 * f2 / (f1 * f1 - f2 * f2); t_satCodeBias satCodeBias; _bsxRef->determineSsrSatCodeBiases(prn, aIF1, aIF2, satCodeBias); QMap osb; for (const t_frqCodeBias& bb : satCodeBias._bias) { osb[QString::fromStdString(bb._rnxType2ch)] = bb._value; } if (!osb.contains(base)) { return false; } for (auto it = osb.constBegin(); it != osb.constEnd(); ++it) { if (it.key() != base) { dcbs[it.key()] = osb[base] - it.value(); } } return !dcbs.isEmpty(); } // per AC: DCBs of its streams QMap > > acDcbs; // AC, signal for (auto itS = _cbLast.constBegin(); itS != _cbLast.constEnd(); ++itS) { const QString& sta = itS.key(); if (sta == staID) { continue; } if (_reference == "CONSENSUS") { if (!votes(sta)) { continue; } if (onlyAC.isEmpty() ? (acOf(sta) == acOf(staID)) : (solOf(sta) != onlyAC || solOf(sta) == solOf(staID))) { continue; } } else if (sta != _reference) { continue; } if (!itS.value().contains(prn)) { continue; } const QMap& bb = itS.value()[prn]; if (!bb.contains(base)) { continue; } for (auto it = bb.constBegin(); it != bb.constEnd(); ++it) { if (it.key() != base) { acDcbs[solOf(sta)][it.key()].append(bb[base] - it.value()); } } } QMap > votesPerSig; for (auto itA = acDcbs.constBegin(); itA != acDcbs.constEnd(); ++itA) { for (auto it = itA.value().constBegin(); it != itA.value().constEnd(); ++it) { votesPerSig[it.key()].append(median(it.value())); } } for (auto it = votesPerSig.constBegin(); it != votesPerSig.constEnd(); ++it) { dcbs[it.key()] = median(it.value()); if (numVotes) { (*numVotes)[it.key()] = it.value().size(); } } return !dcbs.isEmpty(); } // Code bias check: the DCBs of the stream (relative to the first reference // signal, otherwise to another common signal) compared with a reference; // DCBs do not depend on the clock datum. A sign opposite to the reference // for most DCBs indicates the SINEX instead of the SSR sign convention. // References: the Bias SINEX file, the selected reference stream, or each // other AC separately (a median of two ACs cannot decide which one is wrong) //////////////////////////////////////////////////////////////////////////// void t_ssrQc::compareCodeBiases(const QString& staID, char sys, QStringList& lines) { const double MIN_DCB = 0.3; // [m] DCBs used for the sign check const double MIN_DEV = 0.5; // [m] deviating DCBs: > max(MIN_DEV, DEV_FACTOR x median) const int MIN_VOTE = 3; // ACs needed for a consensus DCB (deviating satellites) if (!_cbLast.contains(staID)) { return; } t_ssrRefSig::t_pair pair; if (!t_ssrRefSig::get(sys, pair)) { return; } const QString ref1 = QString::fromStdString(pair.sig1()); class t_cmp { public: unsigned numSat = 0, numSign = 0, numOpp = 0; double sumSqr = 0.0; QVector absDiffs; QMap > maxDiff; // PRN: signal, difference QSet bases; bool opposite() const {return numSign >= 5 && numOpp > numSign / 2;} double rms() const {return absDiffs.isEmpty() ? 0.0 : sqrt(sumSqr / absDiffs.size());} }; // mode 0: file, 1: reference stream or median of the other ACs, 2: one other AC auto compare = [&](int mode, const QString& onlyAC) { t_cmp cc; for (auto itP = _cbLast[staID].constBegin(); itP != _cbLast[staID].constEnd(); ++itP) { const QString& prn = itP.key(); if (prn[0].toLatin1() != sys) { continue; } const QMap& bb = itP.value(); QStringList candidates = QStringList() << ref1 << bb.keys(); QMap dcbs; QMap numVotes; QString base; for (const QString& cand : candidates) { if (bb.contains(cand) && refDcbs(staID, prn, cand, mode == 0, dcbs, onlyAC, &numVotes)) { base = cand; break; } } if (base.isEmpty()) { continue; } bool used = false; for (auto it = dcbs.constBegin(); it != dcbs.constEnd(); ++it) { if (!bb.contains(it.key())) { continue; } if (mode == 1 && _reference == "CONSENSUS" && numVotes.value(it.key()) < MIN_VOTE) { continue; } double dcb = bb[base] - bb[it.key()]; double diff = dcb - it.value(); used = true; cc.sumSqr += diff * diff; cc.absDiffs.append(fabs(diff)); if (fabs(diff) > fabs(cc.maxDiff.value(prn).second)) { cc.maxDiff[prn] = qMakePair("C" + it.key(), diff); } if (fabs(it.value()) >= MIN_DCB) { cc.numSign++; if (dcb * it.value() < 0.0) { cc.numOpp++; } } } if (used) { cc.numSat++; cc.bases.insert("C" + base); } } return cc; }; auto baseStr = [](const t_cmp& cc) { QStringList ll = cc.bases.values(); ll.sort(); return ll.join("/"); }; auto deviating = [&](const t_cmp& cc, const QString& refName) { if (cc.absDiffs.isEmpty() || cc.opposite()) { return; } double limit = std::max(MIN_DEV, DEV_FACTOR * median(cc.absDiffs)); QStringList devs; for (auto it = cc.maxDiff.constBegin(); it != cc.maxDiff.constEnd(); ++it) { if (fabs(it.value().second) > limit) { devs << QString("%1(%2 %3)").arg(it.key()).arg(it.value().first).arg(it.value().second, 0, 'f', 2); } } if (!devs.isEmpty()) { lines << QString("satellites with DCBs deviating from %1 [m]: %2").arg(refName).arg(devs.join(" ")); } }; // Bias SINEX file or the selected reference stream for (int mode = 0; mode < 2; mode++) { if ((mode == 0 && !_bsxRef) || (mode == 1 && _reference == "CONSENSUS")) { continue; } QString refName = (mode == 0) ? QString("Bias SINEX file %1").arg(_bsxName) : _reference; t_cmp cc = compare(mode, QString()); if (cc.absDiffs.isEmpty()) { continue; } lines << QString("code biases compared with %1 (DCBs to %2, %3 satellites, %4 DCBs): RMS of the differences " "%5 m, opposite sign for %6 of %7 DCBs (|DCB| >= %8 m)") .arg(refName).arg(baseStr(cc)).arg(cc.numSat).arg(cc.absDiffs.size()) .arg(cc.rms(), 0, 'f', 3).arg(cc.numOpp).arg(cc.numSign).arg(MIN_DCB, 0, 'f', 1); if (cc.opposite()) { lines << QString("code biases with the opposite sign of %1 for %2 of %3 DCBs - " "SINEX sign convention instead of the SSR one?").arg(refName).arg(cc.numOpp).arg(cc.numSign); } deviating(cc, refName); } // Each other AC separately, and the median of the other ACs for deviating satellites if (_reference == "CONSENSUS") { QSet acs; for (auto itS = _cbLast.constBegin(); itS != _cbLast.constEnd(); ++itS) { if (itS.key() != staID && votes(itS.key()) && solOf(itS.key()) != solOf(staID)) { acs.insert(solOf(itS.key())); } } QStringList acList = acs.values(); acList.sort(); QStringList items; QStringList oppACs; int numACs = 0; QSet bases; for (const QString& ac : acList) { t_cmp cc = compare(2, ac); if (cc.absDiffs.isEmpty()) { continue; } bases.unite(cc.bases); items << QString("%1 RMS %2 m, opposite sign %3/%4").arg(ac).arg(cc.rms(), 0, 'f', 2) .arg(cc.numOpp).arg(cc.numSign); // the sign rule counts the solutions of other ACs only if (acOfSol(ac) != acOf(staID)) { numACs++; if (cc.opposite()) { oppACs << ac; } } } if (!items.isEmpty()) { QStringList ll = bases.values(); ll.sort(); lines << QString("code biases compared with the other solutions (DCBs to %1): %2").arg(ll.join("/")).arg(items.join("; ")); if (numACs >= 2 && oppACs.size() > numACs / 2) { lines << QString("code biases with the opposite sign of %1 of %2 solutions of other ACs (%3) - " "SINEX sign convention instead of the SSR one?") .arg(oppACs.size()).arg(numACs).arg(oppACs.join(" ")); } deviating(compare(1, QString()), QString("the median of the other ACs (at least %1 solutions)").arg(MIN_VOTE)); } } } // Reference point of the orbits of a stream: APC (SSRA...), CoM (SSRC...); // the combined stream INTERNAL refers to the APC //////////////////////////////////////////////////////////////////////////// char t_ssrQc::refPoint(const QString& staID) const { if (staID == "INTERNAL") { return 'A'; // the combined corrections always refer to the APC } const QString& sta = staID; if (sta.startsWith("SSRA")) return 'A'; if (sta.startsWith("SSRC")) return 'C'; return '?'; } // Keep the recent orbit and clock corrections of a stream //////////////////////////////////////////////////////////////////////////// void t_ssrQc::storeHist(const t_orbCorr* orb, const t_clkCorr* clk) { if (orb) { QMap& hist = _orbHist[QString::fromStdString(orb->_staID)] [QString::fromStdString(orb->_prn.toInternalString())]; hist[tKey(orb->_time)] = *orb; while (!hist.isEmpty() && hist.firstKey() < tKey(orb->_time) - HIST_LENGTH) { hist.erase(hist.begin()); } } if (clk) { QMap& hist = _clkHist[QString::fromStdString(clk->_staID)] [QString::fromStdString(clk->_prn.toInternalString())]; hist[tKey(clk->_time)] = *clk; while (!hist.isEmpty() && hist.firstKey() < tKey(clk->_time) - HIST_LENGTH) { hist.erase(hist.begin()); } } } // Compare the orbits and clocks of all streams at epoch tt //////////////////////////////////////////////////////////////////////////// void t_ssrQc::compareEpoch(const bncTime& tt) { double tk = tKey(tt); // Satellite positions and clocks of all streams (latest corrections <= tt) QMap > crd; // stream, PRN (external) QMapIterator > > itSta(_clkHist); while (itSta.hasNext()) { itSta.next(); const QString& staID = itSta.key(); QMapIterator > itPrn(itSta.value()); while (itPrn.hasNext()) { itPrn.next(); const QString& prnInt = itPrn.key(); QMap::const_iterator itC = itPrn.value().upperBound(tk); if (itC == itPrn.value().constBegin()) { continue; } const t_clkCorr& clk = (--itC).value(); const QMap& orbHist = _orbHist[staID][prnInt]; QMap::const_iterator itO = orbHist.upperBound(tk); if (itO == orbHist.constBegin()) { continue; } const t_orbCorr& orb = (--itO).value(); t_eph* eph = _ephUser.ephWithIOD(prnInt, orb._iod); if (eph && outDatedBcep(eph, tt) && fabs(tt - eph->TOC()) > EPH_IOD_REUSE) { eph = 0; } if (!eph || clk._iod != orb._iod) { continue; } eph->setOrbCorr(&orb); eph->setClkCorr(&clk); ColumnVector xc(6), vv(3); if (eph->getCrd(tt, xc, vv, true) != success) { continue; } t_satCrd& sc = crd[staID][QString::fromStdString(clk._prn.toString())]; sc.xx = xc.Rows(1,3); sc.vv = vv; sc.clk = xc(4) * t_CST::c; sc.refPoint = refPoint(staID); // clock referred to the reference signals (as in the combination): // + B_IF of the stream's code biases; the combination (INTERNAL) // refers to them already sc.clkOK = (staID == "INTERNAL"); QString satKey = staID + ' ' + QString::fromStdString(clk._prn.toString()); if (!sc.clkOK && _satStat.contains(satKey) && _satStat[satKey].refBiasOK) { sc.clk += _satStat[satKey].biasIF; sc.clkOK = true; } } } // Satellites per system QMap > satsSys; QMapIterator > itCrd(crd); while (itCrd.hasNext()) { itCrd.next(); for (const QString& prn : itCrd.value().keys()) { satsSys[prn[0].toLatin1()].insert(prn); } } QMapIterator > itSys(satsSys); while (itSys.hasNext()) { itSys.next(); const QSet& sats = itSys.value(); QStringList staList; for (const QString& staID : crd.keys()) { for (const QString& prn : sats) { if (crd[staID].contains(prn)) { staList << staID; break; } } } // Consensus: one value per AC (median of its streams), so that an AC // with several streams (formats, APC/CoM) has one vote; the streams of // the AC compared are left out (leave-one-AC-out) auto acClocks = [&](const QString& prn, const QMap& off, bool useOff) { QMap > perAC; for (const QString& oo : staList) { if (!votes(oo) || !crd[oo].contains(prn) || !crd[oo][prn].clkOK) continue; if (useOff && !off.contains(oo)) continue; perAC[solOf(oo)] << crd[oo][prn].clk - (useOff ? off[oo] : 0.0); } QMap res; for (const QString& ac : perAC.keys()) res[ac] = median(perAC[ac]); return res; }; // Clock datum: offset of each stream w.r.t. the per-satellite consensus // of all ACs (or w.r.t. the reference stream) QMap offset; QMap noOff; QMap > rawAC; // PRN, AC if (_reference == "CONSENSUS") { for (const QString& prn : sats) rawAC[prn] = acClocks(prn, noOff, false); } for (const QString& staID : staList) { QVector diffs; for (const QString& prn : sats) { if (!crd[staID].contains(prn) || !crd[staID][prn].clkOK) continue; if (_reference == "CONSENSUS") { if (rawAC[prn].size() >= 3) diffs << crd[staID][prn].clk - median(rawAC[prn].values().toVector()); } else if (crd.value(_reference).contains(prn) && crd[_reference][prn].clkOK) { diffs << crd[staID][prn].clk - crd[_reference][prn].clk; } } if (!diffs.isEmpty()) { offset[staID] = median(diffs); } } QMap > alignedAC; // PRN, AC if (_reference == "CONSENSUS") { for (const QString& prn : sats) alignedAC[prn] = acClocks(prn, offset, true); } // Differences of each selected stream to its reference for (const QString& staID : staList) { if (!selected(staID.toStdString()) || staID == _reference) { continue; } const QString ownAC = acOf(staID); QMap dClk; QMap dRac; for (const QString& prn : sats) { if (!crd[staID].contains(prn)) continue; const t_satCrd& sc = crd[staID][prn]; QVector clks, xs[3]; if (_reference == "CONSENSUS") { QMapIterator itA(alignedAC[prn]); while (itA.hasNext()) { itA.next(); if (acOfSol(itA.key()) != ownAC) clks << itA.value(); } // orbits: per AC the median of its streams with the same reference point QMap > perAC[3]; for (const QString& oo : staList) { if (!votes(oo) || acOf(oo) == ownAC || !crd[oo].contains(prn)) continue; const t_satCrd& rc = crd[oo][prn]; if (rc.refPoint != sc.refPoint || sc.refPoint == '?') continue; for (int ii = 0; ii < 3; ii++) perAC[ii][solOf(oo)] << rc.xx[ii]; } for (const QString& ac : perAC[0].keys()) { for (int ii = 0; ii < 3; ii++) xs[ii] << median(perAC[ii][ac]); } if (clks.size() < 2) clks.clear(); if (xs[0].size() < 2) for (int ii = 0; ii < 3; ii++) xs[ii].clear(); } else { if (!crd.value(_reference).contains(prn)) continue; const t_satCrd& rc = crd[_reference][prn]; if (rc.clkOK) clks << rc.clk; if (rc.refPoint == sc.refPoint && sc.refPoint != '?') { for (int ii = 0; ii < 3; ii++) xs[ii] << rc.xx[ii]; } } if (sc.clkOK && offset.contains(staID) && !clks.isEmpty()) { dClk[prn] = sc.clk - offset[staID] - median(clks); } else if (!sc.clkOK) { _cmpStat[staID][prn].clkNotReferred += 1; } if (!xs[0].isEmpty()) { ColumnVector dx(3); for (int ii = 0; ii < 3; ii++) dx[ii] = sc.xx[ii] - median(xs[ii]); ColumnVector er = sc.xx / sc.xx.NormFrobenius(); ColumnVector ec = crossproduct(sc.xx, sc.vv); ec /= ec.NormFrobenius(); ColumnVector ea = crossproduct(ec, er); ColumnVector rac(3); rac[0] = DotProduct(dx, er); rac[1] = DotProduct(dx, ea); rac[2] = DotProduct(dx, ec); dRac[prn] = rac; } } if (dClk.isEmpty() && dRac.isEmpty()) { continue; } // remaining common clock offset of this stream and epoch double off = dClk.isEmpty() ? 0.0 : median(dClk.values().toVector()); _cmpEpochs[staID] += 1; QSet prns; for (const QString& prn : dClk.keys()) prns.insert(prn); for (const QString& prn : dRac.keys()) prns.insert(prn); for (const QString& prn : prns) { t_cmpStat& stat = _cmpStat[staID][prn]; if (dRac.contains(prn)) { const ColumnVector& rac = dRac[prn]; stat.numOrb += 1; stat.sR += rac[0] * rac[0]; stat.sA += rac[1] * rac[1]; stat.sC += rac[2] * rac[2]; } if (dClk.contains(prn)) { double dc = dClk[prn] - off; if (_antex && _yawLast.value(staID).contains(prn)) { attitudeSample(staID, prn, tt, crd[staID][prn].xx, crd[staID][prn].vv, dc); } stat.numClk += 1; stat.sClk += dc * dc; stat.mClk += dc; if (dRac.contains(prn)) { double sis = dRac[prn][0] - dc; stat.numSis += 1; stat.sSis += sis * sis; stat.mSis += sis; } } } } } } // Attitude check: one sample per comparison epoch for a stream sending the // yaw angle. The yaw difference of BNC's attitude model ('Computed') minus // the stream is followed continuously over time; the predicted clock // difference of the stream to an AC following the model is // lambda_IF * dpsi / 2pi (see combination, dC_att). A turn starts when the // yaw difference changes by more than ATT_ONSET against the samples 5-15 // minutes before, and ends when it is stable again for ATT_STABLE_T. //////////////////////////////////////////////////////////////////////////// void t_ssrQc::attitudeSample(const QString& staID, const QString& prn, const bncTime& tt, const ColumnVector& xx, const ColumnVector& vv, double dc) { const t_yawLast& yl = _yawLast[staID][prn]; double age = tt - yl.time; if (!yl.time.valid() || fabs(age) > 120.0) { return; } double psiAC = yl.yaw + yl.rate * age; // model yaw, once per satellite and epoch (model state in time order) double tk = tKey(tt); if (tk != _psiModelT) { _psiModel.clear(); _psiModelT = tk; } if (!_psiModel.contains(prn)) { ColumnVector dx(3); double psiM = 0.0; double Mjd = tt.mjd() + tt.daysec() / 86400.0; if (_antex->satCoMcorrection(prn, Mjd, xx, vv, dx, bncAntex::ATT_COMPUTED, 0.0, "ssrQc", &psiM) != success) { return; } _psiModel[prn] = psiM; } // ionosphere-free wavelength of the reference signals, c / (f1 + f2) QString sig1, sig2; t_frequency::type frq1, frq2; if (!refSignals(prn[0].toLatin1(), sig1, sig2, frq1, frq2)) { return; } double lambda = t_CST::c / (t_CST::freq(frq1, 0) + t_CST::freq(frq2, 0)); t_attTrack& tr = _attTrack[staID][prn]; if (tr.hasLast && tk - tr.lastT > ATT_MAX_GAP) { if (tr.active) { attitudeEvent(staID, prn, tr, "interrupted by a gap"); } tr = t_attTrack(); } double dpsi = remainder(_psiModel[prn] - psiAC, 2.0 * M_PI); if (tr.hasLast) { dpsi = tr.lastDPsi + remainder(dpsi - tr.lastDPsi, 2.0 * M_PI); } tr.hasLast = true; tr.lastDPsi = dpsi; tr.lastT = tk; t_attSample smp; smp.t = tk; smp.dc = dc; smp.p = lambda * dpsi / (2.0 * M_PI); smp.dpsi = dpsi; if (!tr.active) { tr.hist << smp; while (!tr.hist.isEmpty() && tk - tr.hist.first().t > ATT_REF_FROM) { tr.hist.removeFirst(); } // reference: samples 5 to 15 minutes before QVector rDc, rP, rPsi; for (const t_attSample& hh : tr.hist) { if (tk - hh.t >= ATT_REF_TO) { rDc << hh.dc; rP << hh.p; rPsi << hh.dpsi; } } if (rPsi.size() >= 3 && fabs(dpsi - median(rPsi)) > ATT_ONSET) { tr.active = true; tr.dc0 = median(rDc); tr.p0 = median(rP); tr.dpsi0 = median(rPsi); for (const t_attSample& hh : tr.hist) { if (tk - hh.t < ATT_REF_TO) tr.event << hh; // the beginning of the turn } tr.hist.clear(); } return; } tr.event << smp; // end: yaw difference stable for ATT_STABLE_T, or the turn too long double tStart = tr.event.first().t; bool stable = (tk - tStart >= ATT_STABLE_T); if (stable) { for (int ii = tr.event.size() - 1; ii >= 0 && tk - tr.event[ii].t <= ATT_STABLE_T; ii--) { if (fabs(tr.event[ii].dpsi - dpsi) > ATT_STABLE) { stable = false; break; } } } if (stable || tk - tStart > ATT_MAX_TURN) { attitudeEvent(staID, prn, tr, stable ? "" : "closed after 3 h"); double keepPsi = tr.lastDPsi, keepT = tr.lastT; tr = t_attTrack(); tr.hasLast = true; tr.lastDPsi = keepPsi; tr.lastT = keepT; tr.hist << smp; } } // Assessment of a turn: observed clock change of the stream relative to the // consensus against the predicted one (regression slope, RMS reduction) //////////////////////////////////////////////////////////////////////////// void t_ssrQc::attitudeEvent(const QString& staID, const QString& prn, t_attTrack& tr, const QString& end) { if (tr.event.size() < 3) { return; } double sxx = 0.0, sxy = 0.0, syy = 0.0, srr = 0.0, maxX = 0.0, maxPsi = 0.0; for (const t_attSample& ss : tr.event) { double x = ss.p - tr.p0; double y = ss.dc - tr.dc0; sxx += x * x; sxy += x * y; syy += y * y; srr += (y - x) * (y - x); if (fabs(x) > fabs(maxX)) maxX = x; if (fabs(ss.dpsi - tr.dpsi0) > fabs(maxPsi)) maxPsi = ss.dpsi - tr.dpsi0; } int nn = tr.event.size(); // changes at the end of the turn: mean of the last 5 minutes double tEnd = tr.event.last().t, xEnd = 0.0, yEnd = 0.0; int nEnd = 0; for (const t_attSample& ss : tr.event) { if (tEnd - ss.t <= 300.0) { xEnd += ss.p - tr.p0; yEnd += ss.dc - tr.dc0; ++nEnd; } } xEnd /= nEnd; yEnd /= nEnd; double rmsObs = sqrt(syy / nn); double rmsRes = sqrt(srr / nn); double slope = (sxx > 0.0) ? sxy / sxx : 0.0; bncTime t1, t2; t1.set(int(tr.event.first().t / 604800.0), fmod(tr.event.first().t, 604800.0)); t2.set(int(tEnd / 604800.0), fmod(tEnd, 604800.0)); QString verdict; if (fabs(maxX) < ATT_MIN_PRED) { // the consensus is assumed to follow BNC's attitude model; a larger clock // change shows that the other ACs model this turn differently verdict = (rmsObs >= ATT_MIN_PRED) ? "clock change not explained by the yaw difference " "(the other ACs model this turn differently?)" : "predicted change too small to assess"; } else if (slope > 0.7 && slope < 1.3 && rmsRes < 0.5 * rmsObs) { verdict = "SSR yaw consistent with the clocks"; } else { verdict = "SSR yaw NOT consistent with the clocks"; } QString line = QString("attitude check %1 %2-%3: yaw difference to the model %4 deg, clock change vs. " "consensus %5 cm, predicted %6 cm (max %7 cm), slope %8, clock RMS %9 -> %10 cm: %11") .arg(prn).arg(QString::fromStdString(t1.timestr(0))).arg(QString::fromStdString(t2.timestr(0))) .arg(maxPsi * 180.0 / M_PI, 0, 'f', 0) .arg(yEnd * 100.0, 0, 'f', 1).arg(xEnd * 100.0, 0, 'f', 1).arg(maxX * 100.0, 0, 'f', 1) .arg(slope, 0, 'f', 2).arg(rmsObs * 100.0, 0, 'f', 1).arg(rmsRes * 100.0, 0, 'f', 1) .arg(verdict); if (!end.isEmpty()) { line += " (" + end + ")"; } _attEvents[staID][prn[0].toLatin1()] << line; } // Ionospheric model (VTEC) //////////////////////////////////////////////////////////////////////////// void t_ssrQc::slotNewTec(t_vTec vTec) { QMutexLocker locker(&_mutex); if (vTec._layers.empty()) { return; } QString sta = QString::fromStdString(vTec._staID); if (!_excluded.contains(sta) || sta == _reference) { QMap& hist = _vtecHist[sta]; hist[tKey(vTec._time)] = vTec; while (!hist.isEmpty() && tKey(vTec._time) - hist.firstKey() > VTEC_HIST) { hist.erase(hist.begin()); } } if (selected(vTec._staID)) { newEpoch(vTec._staID, 'V', VTEC, vTec._time, vTec._updateInt, "VTEC"); QStringList layers; for (const t_vTecLayer& layer : vTec._layers) { layers << QString("%1 km (degree/order %2/%3)").arg(layer._height / 1000.0, 0, 'f', 0) .arg(layer._C.Nrows() - 1).arg(layer._C.Ncols() - 1); } _vtecStat[sta].model = QString("%1 layer(s): %2").arg(vTec._layers.size()).arg(layers.join(", ")); } checkInterval(vTec._time); } // VTEC [TECU] of a model at a geocentric latitude and longitude [rad] and // epoch: spherical harmonics in the sun-fixed frame as used in PPP, sum of // the layers (negative layer values set to zero as in PPP) //////////////////////////////////////////////////////////////////////////// double t_ssrQc::vtecAt(const t_vTec& vTec, double lat, double lon, const bncTime& tt, bool& negative) { double epoch = fmod(tt.gpssec(), 86400.0); double lonS = fmod(lon + (epoch - 50400.0) * M_PI / 43200.0, 2.0 * M_PI); double vtec = 0.0; negative = false; for (const t_vTecLayer& layer : vTec._layers) { int N = layer._C.Nrows() - 1; int M = layer._C.Ncols() - 1; double vv = 0.0; for (int n = 0; n <= N; n++) { for (int m = 0; m <= min(n, M); m++) { double pnm = associatedLegendreFunction(n, m, sin(lat)); double fac = (m == 0) ? sqrt(2.0 * n + 1.0) : sqrt(2.0 * (2.0 * n + 1.0) * factorial(n - m) / factorial(n + m)); vv += (layer._C[n][m] * cos(m * lonS) + layer._S[n][m] * sin(m * lonS)) * pnm * fac; } } if (vv < 0.0) { negative = true; vv = 0.0; } vtec += vv; } return vtec; } // VTEC comparison: each model (latest one not older than twice its update // interval) evaluated on a global grid and compared with the median of the // other AC solutions (one vote per solution, own AC left out, combination // products not voting) or with the reference stream //////////////////////////////////////////////////////////////////////////// void t_ssrQc::compareVtec(const bncTime& tt) { double tk = tKey(tt); QMap models; QMapIterator > itH(_vtecHist); while (itH.hasNext()) { itH.next(); QMap::const_iterator it = itH.value().upperBound(tk); if (it == itH.value().constBegin()) { continue; } --it; const t_vTec& vTec = it.value(); double interval = (vTec._updateInt < unsigned(ssrUpdateInt.size()) && ssrUpdateInt[vTec._updateInt] > 0) ? ssrUpdateInt[vTec._updateInt] : VTEC_CMP_SAMPL; double maxAge = min(3600.0, max(120.0, 2.0 * interval)); if (tk - it.key() <= maxAge) { models[itH.key()] = &vTec; } } if (models.isEmpty()) { return; } // grid values of all models QVector > grid; for (double lat = -80.0; lat <= 80.0 + 1e-6; lat += VTEC_GRID_LAT) { for (double lon = -180.0; lon < 180.0 - 1e-6; lon += VTEC_GRID_LON) { grid << QPair(lat * M_PI / 180.0, lon * M_PI / 180.0); } } QMap > val; QMapIterator itM(models); while (itM.hasNext()) { itM.next(); QVector& vv = val[itM.key()]; vv.resize(grid.size()); bool selectedSta = selected(itM.key().toStdString()); t_vtecStat* st = selectedSta ? &_vtecStat[itM.key()] : 0; for (int ii = 0; ii < grid.size(); ii++) { bool negative = false; vv[ii] = vtecAt(*itM.value(), grid[ii].first, grid[ii].second, tt, negative); if (st) { if (st->numGrid == 0 || vv[ii] < st->minVal) st->minVal = vv[ii]; if (st->numGrid == 0 || vv[ii] > st->maxVal) st->maxVal = vv[ii]; st->numGrid += 1; if (negative) st->numNeg += 1; } } } // comparison for (const QString& staID : models.keys()) { if (!selected(staID.toStdString())) { continue; } QMap perSol; // solution: streams for (const QString& oo : models.keys()) { if (oo == staID) continue; if (_reference == "CONSENSUS") { if (!votes(oo) || acOf(oo) == acOf(staID)) continue; } else if (oo != _reference) { continue; } perSol[solOf(oo)] << oo; } if (perSol.isEmpty()) { continue; } t_vtecStat& st = _vtecStat[staID]; st.numCmp += 1; st.numSol += perSol.size(); for (int ii = 0; ii < grid.size(); ii++) { QVector solVal; for (const QStringList& streams : perSol) { QVector vv; for (const QString& oo : streams) vv << val[oo][ii]; solVal << median(vv); } double diff = val[staID][ii] - median(solVal); st.numDiff += 1; st.sDiff += diff; st.sDiff2 += diff * diff; st.maxAbs = max(st.maxAbs, fabs(diff)); } } } // Write the report of the current interval and reset the statistics //////////////////////////////////////////////////////////////////////////// void t_ssrQc::writeReport(bool final) { if (_epoStat.isEmpty()) { return; } bncTime endTime = final ? _lastTime : _intervalStart + _interval; // IODs not received: early or unknown (pending ones younger than // EPH_EARLY_MAX are decided in the next interval) for (const QString& satKey : _pendingIod.keys()) { resolvePendingIod(satKey, final); } // Healthy and current broadcast ephemerides per system QMap > ephSats; for (const QString& prnInt : _ephUser.prnList()) { t_eph* eph = _ephUser.ephLast(prnInt); if (eph && eph->checkState() == t_eph::ok && !outDatedBcep(eph, endTime)) { ephSats[eph->prn().system()].insert(QString::fromStdString(eph->prn().toString())); } } // Streams and systems QMap > streams; QMapIterator itEpo(_epoStat); while (itEpo.hasNext()) { itEpo.next(); QStringList hlp = itEpo.key().split(' '); streams[hlp[0]].insert(hlp[1][0].toLatin1()); } QMapIterator > itSta(streams); while (itSta.hasNext()) { itSta.next(); const QString& staID = itSta.key(); QList systems = itSta.value().values(); std::sort(systems.begin(), systems.end()); ofstream& out = streamOut(staID, _intervalStart); if (final) { endTime = _staLast.value(staID, _lastTime); if (endTime < _intervalStart) { // stream without data in the last interval endTime = _lastTime; } } out << endl << "=== SSR QC " << staID.toStdString() << ' ' << _intervalStart.datestr() << ' ' << _intervalStart.timestr(0) << " - " << endTime.datestr() << ' ' << endTime.timestr(0) << (final ? " (end of data)" : "") << " ===" << endl << " Sys Type Epochs Interval nom/med/max [s] Gaps Out-of-order" << " Latency med/max [s] Sats" << endl; for (char sys : systems) { for (int type = ORB; type <= VTEC; type++) { QString kk = QString("%1 %2 %3").arg(staID).arg(sys).arg(type); if (!_epoStat.contains(kk)) { continue; } const t_epoStat& stat = _epoStat[kk]; double medInt = median(stat.intervals); double maxInt = stat.intervals.isEmpty() ? 0.0 : *std::max_element(stat.intervals.begin(), stat.intervals.end()); double ref = max(double(stat.nominal), medInt); unsigned gaps = 0; for (double dt : stat.intervals) { if (ref > 0.0 && dt > 2.0 * ref) { ++gaps; } } double maxLat = stat.latencies.isEmpty() ? 0.0 : *std::max_element(stat.latencies.begin(), stat.latencies.end()); out << " " << sys << " " << left << setw(10) << TYPE_NAME[type] << right << setw(8) << stat.numEpo << setw(10) << stat.nominal << " /" << setw(6) << setprecision(1) << fixed << medInt << " /" << setw(7) << maxInt << setw(6) << gaps << setw(14) << stat.numOutOfOrder << setw(10) << median(stat.latencies) << " /" << setw(7) << maxLat << setw(7) << stat.sats.size() << endl; } } // Per system: completeness, ephemeris, biases, plausibility for (char sys : systems) { // Ionosphere (VTEC): model, values on the grid, comparison if (sys == 'V') { QStringList lines; QString kk = QString("%1 %2 %3").arg(staID).arg(sys).arg(int(VTEC)); if (_epoStat.contains(kk)) { const t_epoStat& stat = _epoStat[kk]; double medInt = median(stat.intervals); if (stat.nominal > 0 && stat.intervals.size() > 1 && (medInt > 1.5 * stat.nominal || medInt < stat.nominal / 1.5)) { lines << QString("VTEC: declared update interval %1 s, actual %2 s") .arg(stat.nominal).arg(medInt, 0, 'f', 1); } } if (_vtecStat.contains(staID)) { const t_vtecStat& vs = _vtecStat[staID]; if (!vs.model.isEmpty()) { lines << "VTEC model: " + vs.model; } if (vs.numGrid) { lines << QString("VTEC on a global %1 x %2 deg grid: %3 ... %4 TECU, negative (set to zero) %5 %") .arg(VTEC_GRID_LAT, 0, 'f', 0).arg(VTEC_GRID_LON, 0, 'f', 0) .arg(vs.minVal, 0, 'f', 1).arg(vs.maxVal, 0, 'f', 1) .arg(100.0 * vs.numNeg / vs.numGrid, 0, 'f', 1); } if (vs.numDiff) { QString refText = (_reference == "CONSENSUS") ? QString("consensus of the other ACs (%1 solutions on average)") .arg(double(vs.numSol) / vs.numCmp, 0, 'f', 1) : QString("stream %1").arg(_reference); double mean = vs.sDiff / vs.numDiff; double rms = sqrt(vs.sDiff2 / vs.numDiff); lines << QString("VTEC comparison with %1, %2 epochs: mean %3 TECU, RMS %4 TECU, max |diff| %5 TECU") .arg(refText).arg(vs.numCmp).arg(mean, 0, 'f', 2).arg(rms, 0, 'f', 2) .arg(vs.maxAbs, 0, 'f', 1); } else { lines << "VTEC not compared: no other model available"; } } for (const QString& line : lines) { out << " " << sys << " " << line.toStdString() << endl; } continue; } QMap iodEarly, outdated; double maxEarly = 0.0; QMap missOrb, missClk, missCB, missPB, iodUnknown, unhealthy, noEph, jumps, missRef, noYaw, yawPartly; unsigned numPB = 0, numYaw = 0, yawSats = 0; double maxRadial = 0.0, maxAlongCross = 0.0; QString maxRadialPrn, maxAlongCrossPrn; QVector biasIF; double maxBiasIF = 0.0; QString biasIFPrn; const QSet& ephs = ephSats[sys]; for (int type = ORB; type <= PBIAS; type++) { QString kk = QString("%1 %2 %3").arg(staID).arg(sys).arg(type); if (!_epoStat.contains(kk)) { continue; } QMap& miss = (type == ORB) ? missOrb : (type == CLK) ? missClk : (type == CBIAS) ? missCB : missPB; for (const QString& prn : ephs) { if (!_epoStat[kk].sats.contains(prn)) { miss[prn] = 0; } } } QMapIterator itSat(_satStat); while (itSat.hasNext()) { itSat.next(); QStringList hlp = itSat.key().split(' '); if (hlp[0] != staID || hlp[1][0].toLatin1() != sys) { continue; } const QString& prn = hlp[1]; const t_satStat& stat = itSat.value(); if (stat.iodUnknown) iodUnknown[prn] = stat.iodUnknown; if (stat.iodEarly) { iodEarly[prn] = stat.iodEarly; maxEarly = max(maxEarly, stat.maxEarly); } if (stat.ephOutdated) outdated[prn] = stat.ephOutdated; if (stat.ephUnhealthy) unhealthy[prn] = stat.ephUnhealthy; if (stat.noEph) noEph[prn] = stat.noEph; if (stat.clkJumps) jumps[prn] = stat.clkJumps; if (stat.maxRadial > maxRadial) { maxRadial = stat.maxRadial; maxRadialPrn = prn; } if (stat.maxAlongCross > maxAlongCross) { maxAlongCross = stat.maxAlongCross; maxAlongCrossPrn = prn; } if (!stat.missingRefSig.isEmpty()) { QStringList sigs = stat.missingRefSig.values(); sigs.sort(); missRef[prn + "[" + sigs.join("/") + "]"] = 0; } if (stat.refBiasOK) { biasIF.push_back(stat.biasIF); if (biasIFPrn.isEmpty() || fabs(stat.biasIF) > maxBiasIF) { maxBiasIF = fabs(stat.biasIF); biasIFPrn = prn; } } if (stat.numPB) { numPB += stat.numPB; numYaw += stat.numYaw; if (stat.numYaw) { yawSats++; if (stat.numYaw < stat.numPB) { yawPartly[prn] = stat.numPB - stat.numYaw; } } else { noYaw[prn] = 0; } } } // only findings are listed QStringList lines; for (int type = ORB; type <= PBIAS; type++) { QString kk = QString("%1 %2 %3").arg(staID).arg(sys).arg(type); if (_epoStat.contains(kk)) { const t_epoStat& stat = _epoStat[kk]; double medInt = median(stat.intervals); if (stat.nominal > 0 && stat.intervals.size() > 1 && (medInt > 1.5 * stat.nominal || medInt < stat.nominal / 1.5)) { lines << QString("%1: declared update interval %2 s, actual %3 s") .arg(TYPE_NAME[type]).arg(stat.nominal).arg(medInt, 0, 'f', 1); } } } auto addSats = [&lines](const QString& text, const QMap& sats) { if (!sats.isEmpty()) { lines << text + ":" + satList(sats); } }; addSats("orbit corrections missing (healthy broadcast ephemeris available)", missOrb); addSats("clock corrections missing (healthy broadcast ephemeris available)", missClk); addSats("code biases missing (healthy broadcast ephemeris available)", missCB); addSats("phase biases missing (healthy broadcast ephemeris available)", missPB); addSats("corrections referring to an IOD of no received broadcast ephemeris", iodUnknown); if (!iodEarly.isEmpty()) { addSats(QString("corrections referring to an IOD received from the ephemeris stream(s) " "only later (up to %1 s)").arg(maxEarly, 0, 'f', 0), iodEarly); } addSats("corrections referring to a broadcast ephemeris outside its validity", outdated); addSats("corrections for unhealthy broadcast ephemeris", unhealthy); addSats("corrections for satellites without broadcast ephemeris", noEph); QString staSys = QString("%1 %2").arg(staID).arg(sys); if (_codeSignals.contains(staSys)) { QStringList sigs = _codeSignals[staSys].values(); sigs.sort(); lines << "code bias signals: " + sigs.join(" "); addSats("code biases of the reference signals missing", missRef); if (biasIF.size() > 1) { double mean = 0.0; for (double bb : biasIF) mean += bb / biasIF.size(); double var = 0.0; for (double bb : biasIF) var += (bb - mean) * (bb - mean) / biasIF.size(); // the median shows whether the clocks refer to other signals (all // satellites) or single satellites have inconsistent biases QVector absIF; for (double bb : biasIF) absIF << fabs(bb); lines << QString("ionosphere-free combination B_IF of the reference signal biases: " "std. dev. %1 m, median |B_IF| %2 m, max |B_IF| %3 m (%4) (%5 satellites)") .arg(sqrt(var), 0, 'f', 3).arg(median(absIF), 0, 'f', 3).arg(maxBiasIF, 0, 'f', 3) .arg(biasIFPrn).arg(biasIF.size()); } } compareCodeBiases(staID, sys, lines); if (_phaseSignals.contains(staSys)) { QStringList sigs = _phaseSignals[staSys].values(); sigs.sort(); lines << "phase bias signals: " + sigs.join(" "); } if (numPB) { if (numYaw == 0) { lines << "yaw angle: not provided"; } else { lines << QString("yaw angle: provided for %1 of %2 satellites (%3 % of the phase bias records)") .arg(yawSats).arg(yawSats + noYaw.size()).arg(100.0 * numYaw / numPB, 0, 'f', 1); addSats("phase biases without yaw angle", noYaw); addSats("phase biases partly without yaw angle (records)", yawPartly); } } for (const QString& line : _attEvents.value(staID).value(sys)) { lines << line; } addSats(QString("clock jumps > %1 m").arg(MAX_CLK_JUMP), jumps); if (!maxRadialPrn.isEmpty()) { lines << QString("largest orbit corrections: radial %1 m (%2), along/cross %3 m (%4)") .arg(maxRadial, 0, 'f', 2).arg(maxRadialPrn).arg(maxAlongCross, 0, 'f', 2).arg(maxAlongCrossPrn); } // stream comparison if (_cmpStat.contains(staID)) { unsigned nSat = 0, nClk = 0, nOrb = 0, nSis = 0; double sClk = 0.0, sR = 0.0, sA = 0.0, sC = 0.0, sSis = 0.0; double vClk = 0.0, vSis = 0.0; // sums of squares about the satellite means QMap rmsSis, rmsClk, rmsOrb; QMap notReferred; QMapIterator itC(_cmpStat[staID]); while (itC.hasNext()) { itC.next(); if (itC.key()[0].toLatin1() != sys) continue; const t_cmpStat& st = itC.value(); if (st.numClk == 0 && st.numOrb == 0) { if (st.clkNotReferred) notReferred[itC.key()] = 0; continue; } ++nSat; if (st.clkNotReferred) notReferred[itC.key()] = 0; nClk += st.numClk; sClk += st.sClk; nOrb += st.numOrb; sR += st.sR; sA += st.sA; sC += st.sC; nSis += st.numSis; sSis += st.sSis; if (st.numClk) vClk += st.sClk - st.mClk * st.mClk / st.numClk; if (st.numSis) vSis += st.sSis - st.mSis * st.mSis / st.numSis; if (st.numSis) rmsSis[itC.key()] = sqrt(st.sSis / st.numSis); else if (st.numClk) rmsClk[itC.key()] = sqrt(st.sClk / st.numClk); if (st.numOrb) rmsOrb[itC.key()] = sqrt((st.sR + st.sA + st.sC) / st.numOrb); } if (nSat) { QString refText = (_reference == "CONSENSUS") ? QString("consensus of the other ACs") : QString("stream %1").arg(_reference); QStringList parts; if (nClk) parts << QString("clock %1 (STD %2)").arg(sqrt(sClk / nClk), 0, 'f', 3) .arg(sqrt(max(0.0, vClk) / nClk), 0, 'f', 3); if (nOrb) parts << QString("radial %1, along %2, cross %3") .arg(sqrt(sR / nOrb), 0, 'f', 3).arg(sqrt(sA / nOrb), 0, 'f', 3) .arg(sqrt(sC / nOrb), 0, 'f', 3); if (nSis) parts << QString("SIS (radial - clock) %1 (STD %2)").arg(sqrt(sSis / nSis), 0, 'f', 3) .arg(sqrt(max(0.0, vSis) / nSis), 0, 'f', 3); lines << QString("comparison with %1 (%2 satellites), RMS (STD about the satellite means) [m]: %3") .arg(refText).arg(nSat).arg(parts.join(", ")); if (!nOrb) { lines << "orbits not compared: no other stream with the same reference point (APC/CoM)"; } addSats("clocks not compared (code biases of the reference signals missing)", notReferred); auto deviating = [&lines](const QString& what, const QMap& rms) { if (rms.size() < 2) return; double med = median(rms.values().toVector()); QStringList dev; QMapIterator itR(rms); while (itR.hasNext()) { itR.next(); if (itR.value() > max(DEV_MIN, DEV_FACTOR * med)) { dev << QString("%1(%2)").arg(itR.key()).arg(itR.value(), 0, 'f', 2); } } if (!dev.isEmpty()) { lines << QString("satellites deviating from the reference, RMS %1 [m]: %2").arg(what).arg(dev.join(" ")); } }; deviating("SIS", rmsSis); deviating("clock", rmsClk); deviating("orbit (3D)", rmsOrb); } } for (const QString& line : lines) { out << " " << sys << " " << line.toStdString() << endl; } } out.flush(); } emit newMessage(QString("SSR QC: report %1 %2 of %3 stream(s) written to %4") .arg(QString::fromStdString(_intervalStart.datestr())) .arg(QString::fromStdString(_intervalStart.timestr(0))) .arg(streams.size()).arg(_dirName).toLatin1(), false); _cmpStat.clear(); _cmpEpochs.clear(); _attEvents.clear(); for (QMap::iterator it = _vtecStat.begin(); it != _vtecStat.end(); ++it) { t_vtecStat fresh; fresh.model = it.value().model; it.value() = fresh; } // reset, keeping the state needed across intervals for (QMap::iterator it = _epoStat.begin(); it != _epoStat.end(); ++it) { t_epoStat& stat = it.value(); bncTime last = stat.last; int nominal = stat.nominal; stat = t_epoStat(); stat.last = last; stat.nominal = nominal; } for (QMap::iterator it = _satStat.begin(); it != _satStat.end(); ++it) { t_satStat& stat = it.value(); t_satStat fresh; fresh.lastClkTime = stat.lastClkTime; fresh.lastIod = stat.lastIod; fresh.lastClk = stat.lastClk; fresh.missingRefSig = stat.missingRefSig; fresh.refBiasOK = stat.refBiasOK; fresh.biasIF = stat.biasIF; stat = fresh; } }