source: ntrip/trunk/BNC/src/ssrQc.cpp

Last change on this file was 11119, checked in by stuerze, 6 days ago

SSR QC improved

File size: 59.9 KB
RevLine 
[11070]1// Part of BNC, a utility for retrieving decoding and
2// converting GNSS data streams from NTRIP broadcasters.
3//
4// Copyright (C) 2007
5// German Federal Agency for Cartography and Geodesy (BKG)
6// http://www.bkg.bund.de
7// Czech Technical University Prague, Department of Geodesy
8// http://www.fsv.cvut.cz
9//
10// Email: euref-ip@bkg.bund.de
11//
12// This program is free software; you can redistribute it and/or
13// modify it under the terms of the GNU General Public License
14// as published by the Free Software Foundation, version 2.
15//
16// This program is distributed in the hope that it will be useful,
17// but WITHOUT ANY WARRANTY; without even the implied warranty of
18// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19// GNU General Public License for more details.
20//
21// You should have received a copy of the GNU General Public License
22// along with this program; if not, write to the Free Software
23// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
24
25/* -------------------------------------------------------------------------
26 * BKG NTRIP Client
27 * -------------------------------------------------------------------------
28 *
29 * Class: t_ssrQc
30 *
31 * Purpose: Quality control of decoded SSR streams
32 *
33 * Created: 05-Oct-2026
34 *
35 * Changes:
36 *
37 * -----------------------------------------------------------------------*/
38
39#include <algorithm>
40#include <cmath>
41#include <iomanip>
42
43#include "ssrQc.h"
[11087]44#include "ssrRefSig.h"
45#include "combination/bncbiassnx.h"
[11070]46#include "bnccore.h"
47#include "bncutils.h"
48#include "bncconst.h"
[11075]49#include "bncsettings.h"
50#include "combination/bnccomb.h"
[11070]51
52using namespace std;
53
54namespace {
55 const double MAX_CLK_JUMP = 0.5; // [m] clock change beyond its rate between consecutive epochs
[11073]56 const double EPH_WARMUP = 120.0; // [s] no ephemeris checks at the start
[11075]57 const double CMP_SAMPL = 30.0; // [s] sampling of the stream comparison
58 const double CMP_DELAY = 60.0; // [s] epochs compared when data this much newer arrived
59 const double HIST_LENGTH = 300.0; // [s] corrections kept for the comparison
60 const double DEV_MIN = 0.05; // [m] deviating satellites: RMS above max(DEV_MIN,
61 const double DEV_FACTOR = 3.0; // DEV_FACTOR x median RMS of the system)
[11117]62 const double VTEC_CMP_SAMPL = 300.0; // [s] sampling of the VTEC comparison
63 const double VTEC_CMP_DELAY = 120.0; // [s] VTEC epochs compared when data this much newer arrived
64 const double VTEC_HIST = 3600.0; // [s] VTEC models kept for the comparison
65 const double VTEC_GRID_LAT = 10.0; // [deg] grid of the VTEC evaluation, latitudes -80 ... 80
66 const double VTEC_GRID_LON = 15.0; // [deg]
[11075]67
68 double tKey(const bncTime& tt) {
69 return tt.gpsw() * 604800.0 + tt.gpssec();
70 }
[11117]71 const char* TYPE_NAME[] = {"ORBIT", "CLOCK", "CODE_BIAS", "PHASE_BIAS", "VTEC"};
[11070]72
73 double median(QVector<double> vv) {
74 if (vv.isEmpty()) {
75 return 0.0;
76 }
77 std::sort(vv.begin(), vv.end());
78 int nn = vv.size();
79 return (nn % 2) ? vv[nn/2] : 0.5 * (vv[nn/2-1] + vv[nn/2]);
80 }
81
82 bncTime currentTime() {
83 int week = 0;
84 double sec = 0.0;
85 currentGPSWeeks(week, sec);
86 return bncTime(week, sec);
87 }
88
89 // satellite list "G01 G05(12)", counts shown if not all epochs concerned
90 QString satList(const QMap<QString, unsigned>& sats, unsigned maxNum = 20) {
91 QString str;
92 unsigned num = 0;
93 QMapIterator<QString, unsigned> it(sats);
94 while (it.hasNext()) {
95 it.next();
96 if (++num > maxNum) {
97 str += QString(" ... (%1 satellites)").arg(sats.size());
98 break;
99 }
100 str += " " + it.key();
101 if (it.value() > 0) {
102 str += QString("(%1)").arg(it.value());
103 }
104 }
105 return str.isEmpty() ? " -" : str;
106 }
107}
108
109// Constructor
110////////////////////////////////////////////////////////////////////////////
[11075]111t_ssrQc::t_ssrQc(const QString& dirName, double interval, const QStringList& streams,
112 const QString& reference) : _ephUser(true) {
113 _dirName = dirName;
114 _reference = reference.isEmpty() ? QString("CONSENSUS") : reference;
[11070]115 _interval = interval > 0.0 ? interval : 3600.0;
[11081]116 // "ALL" or a list of streams; streams with a leading '!' are excluded
117 // (from the reports and from the consensus)
118 bool all = false;
[11075]119 for (const QString& sta : streams) {
[11081]120 if (sta.startsWith('!')) {
121 _excluded.insert(sta.mid(1));
[11075]122 }
[11081]123 else if (sta.toUpper() == "ALL") {
124 all = true;
125 }
126 else {
127 _streams.insert(sta);
128 }
[11075]129 }
[11081]130 if (all) {
131 _streams.clear();
132 }
[11070]133
134 connect(this, SIGNAL(newMessage(QByteArray,bool)), BNC_CORE, SLOT(slotMessage(const QByteArray,bool)));
[11087]135 for (const QString& err : t_ssrRefSig::init()) {
136 emit newMessage(("SSR QC: " + err).toLatin1(), true);
137 }
138 // reference code biases from a Bias SINEX file (optional)
139 _bsxRef = 0;
140 bncSettings settings;
141 QString bsxFile = settings.value("ssrQcBiasFile").toString();
142 expandEnvVar(bsxFile);
143 if (!bsxFile.isEmpty()) {
144 _bsxRef = new bncBiasSnx(true);
145 if (_bsxRef->readFile(bsxFile) == success) {
146 _bsxName = QFileInfo(bsxFile).fileName();
147 }
148 else {
149 emit newMessage(("SSR QC: wrong Bias SINEX file " + bsxFile).toLatin1(), true);
150 delete _bsxRef;
151 _bsxRef = 0;
152 }
153 }
[11070]154 connect(BNC_CORE, SIGNAL(newOrbCorrections(QList<t_orbCorr>)),
155 this, SLOT(slotNewOrbCorrections(QList<t_orbCorr>)), Qt::DirectConnection);
156 connect(BNC_CORE, SIGNAL(newClkCorrections(QList<t_clkCorr>)),
157 this, SLOT(slotNewClkCorrections(QList<t_clkCorr>)), Qt::DirectConnection);
158 connect(BNC_CORE, SIGNAL(newCodeBiases(QList<t_satCodeBias>)),
159 this, SLOT(slotNewCodeBiases(QList<t_satCodeBias>)), Qt::DirectConnection);
160 connect(BNC_CORE, SIGNAL(newPhaseBiases(QList<t_satPhaseBias>)),
161 this, SLOT(slotNewPhaseBiases(QList<t_satPhaseBias>)), Qt::DirectConnection);
[11117]162 connect(BNC_CORE, SIGNAL(newTec(t_vTec)),
163 this, SLOT(slotNewTec(t_vTec)), Qt::DirectConnection);
[11075]164 // combined corrections (stream INTERNAL)
165 if (bncComb::exists()) {
166 connect(BNC_CMB, SIGNAL(newOrbCorrections(QList<t_orbCorr>)),
167 this, SLOT(slotNewOrbCorrections(QList<t_orbCorr>)), Qt::DirectConnection);
168 connect(BNC_CMB, SIGNAL(newClkCorrections(QList<t_clkCorr>)),
169 this, SLOT(slotNewClkCorrections(QList<t_clkCorr>)), Qt::DirectConnection);
170 }
171}
[11070]172
[11075]173// Stream selected for the QC
174////////////////////////////////////////////////////////////////////////////
175bool t_ssrQc::selected(const string& staID) const {
[11081]176 QString sta = QString::fromStdString(staID);
177 return !_excluded.contains(sta) && (_streams.isEmpty() || _streams.contains(sta));
[11070]178}
179
[11081]180// Analysis center of a stream: the 3 characters of the AC in mountpoints
[11091]181// like SSRA00CNE1 or OSBC00WHU1, otherwise the stream name itself
[11081]182////////////////////////////////////////////////////////////////////////////
183QString t_ssrQc::acOf(const QString& staID) {
[11117]184 static const QRegularExpression re("^(?:SSR[AC]|OSB[AC]|IONO)[0-9]{2}([A-Z0-9]{3})[0-9]$");
[11081]185 QRegularExpressionMatch mm = re.match(staID);
186 return mm.hasMatch() ? mm.captured(1) : staID;
187}
188
[11091]189// Solution of an AC: AC and the two digits of the mountpoint, e.g. GFZ00 for
190// SSRA00GFZ0/SSRC00GFZ1 and GFZ01 for SSRA01GFZ0; the streams of a solution
191// (formats, APC/CoM) have one vote in the consensus
192////////////////////////////////////////////////////////////////////////////
193QString t_ssrQc::solOf(const QString& staID) {
[11117]194 static const QRegularExpression re("^(?:SSR[AC]|OSB[AC]|IONO)([0-9]{2})([A-Z0-9]{3})[0-9]$");
[11091]195 QRegularExpressionMatch mm = re.match(staID);
196 return mm.hasMatch() ? mm.captured(2) + mm.captured(1) : staID;
197}
198
199// AC of a solution (GFZ01 -> GFZ)
200////////////////////////////////////////////////////////////////////////////
201QString t_ssrQc::acOfSol(const QString& sol) {
202 static const QRegularExpression re("^([A-Z0-9]{3})[0-9]{2}$");
203 QRegularExpressionMatch mm = re.match(sol);
204 return mm.hasMatch() ? mm.captured(1) : sol;
205}
206
[11081]207// Stream part of the consensus: not excluded, and not a combination product
208// (INTERNAL, IGS combinations), as these are derived from the other streams
209////////////////////////////////////////////////////////////////////////////
210bool t_ssrQc::votes(const QString& staID) const {
211 return !_excluded.contains(staID) && staID != "INTERNAL" && acOf(staID) != "IGS";
212}
213
[11075]214// Daily QC file of a stream: <staID>_S_<YYYYDDD>0000_01D_QC.txt
215////////////////////////////////////////////////////////////////////////////
216ofstream& t_ssrQc::streamOut(const QString& staID, const bncTime& time) {
217 unsigned year, month, day;
218 time.civil_date(year, month, day);
219 QDate date(year, month, day);
220 QString name = QDir(_dirName).filePath(QString("%1_S_%2%3%4_01D_QC.txt").arg(staID)
221 .arg(year).arg(date.dayOfYear(), 3, 10, QChar('0')).arg("0000"));
222 if (_outNames.value(staID) != name) {
223 delete _outs.value(staID, 0);
224 bool exists = QFile::exists(name);
225 ofstream* out = new ofstream(name.toLocal8Bit().data(), ios_base::out | ios_base::app);
226 if (!exists) {
227 *out << "SSR QC of stream " << staID.toStdString() << ", report interval " << _interval << " s" << endl
228 << "Checks: epochs and update intervals, gaps (interval > 2 x max(declared, median)), latency,"
229 << " completeness w.r.t. healthy broadcast ephemerides," << endl
[11085]230 << "IOD matching, code and phase bias signals, yaw angle, code biases of the reference signals, clock jumps > "
[11117]231 << MAX_CLK_JUMP << " m, largest orbit corrections, VTEC models (system V)" << endl
[11087]232 << "Reference signals of the clocks: " << t_ssrRefSig::currentString().toStdString() << endl;
[11075]233 }
234 _outs[staID] = out;
235 _outNames[staID] = name;
236 }
237 return *_outs[staID];
238}
239
[11070]240// Destructor
241////////////////////////////////////////////////////////////////////////////
242t_ssrQc::~t_ssrQc() {
243 QMutexLocker locker(&_mutex);
244 writeReport(true);
[11075]245 for (ofstream* out : _outs) {
246 delete out;
247 }
[11087]248 delete _bsxRef;
[11070]249}
250
251// Key of the epoch statistics
252////////////////////////////////////////////////////////////////////////////
253QString t_ssrQc::key(const string& staID, char sys, e_type type) {
254 return QString("%1 %2 %3").arg(staID.c_str()).arg(sys).arg(int(type));
255}
256
[11087]257// Reference signals of the clocks (as in the combination, option ssrRefSignals)
[11070]258////////////////////////////////////////////////////////////////////////////
259bool t_ssrQc::refSignals(char sys, QString& sig1, QString& sig2,
260 t_frequency::type& frq1, t_frequency::type& frq2) {
[11087]261 t_ssrRefSig::t_pair pair;
262 if (!t_ssrRefSig::get(sys, pair)) {
263 return false;
[11070]264 }
[11087]265 sig1 = QString::fromStdString(pair.sig1());
266 sig2 = QString::fromStdString(pair.sig2());
267 frq1 = pair.frq1;
268 frq2 = pair.frq2;
269 return true;
[11070]270}
271
272// Start a new report interval if the epoch is beyond the current one
273////////////////////////////////////////////////////////////////////////////
274void t_ssrQc::checkInterval(const bncTime& time) {
275 if (!_intervalStart.valid()) {
276 double sec = floor(time.gpssec() / _interval) * _interval;
277 _intervalStart = bncTime(time.gpsw(), sec);
278 }
[11075]279 // stream comparison of the epochs already complete for all streams
280 if (!_nextCmpEpoch.valid()) {
281 double sec = ceil(time.gpssec() / CMP_SAMPL) * CMP_SAMPL;
282 _nextCmpEpoch = bncTime(time.gpsw(), sec);
283 }
284 while (time - _nextCmpEpoch >= CMP_DELAY) {
285 compareEpoch(_nextCmpEpoch);
286 _nextCmpEpoch = _nextCmpEpoch + CMP_SAMPL;
287 }
[11117]288 // VTEC comparison
289 if (!_vtecHist.isEmpty()) {
290 if (!_nextVtecCmp.valid()) {
291 double sec = ceil(time.gpssec() / VTEC_CMP_SAMPL) * VTEC_CMP_SAMPL;
292 _nextVtecCmp = bncTime(time.gpsw(), sec);
293 }
294 while (time - _nextVtecCmp >= VTEC_CMP_DELAY) {
295 compareVtec(_nextVtecCmp);
296 _nextVtecCmp = _nextVtecCmp + VTEC_CMP_SAMPL;
297 }
298 }
[11070]299 if (time - _intervalStart >= _interval) {
300 writeReport(false);
301 double sec = floor(time.gpssec() / _interval) * _interval;
302 _intervalStart = bncTime(time.gpsw(), sec);
303 }
304 if (!_lastTime.valid() || _lastTime < time) {
305 _lastTime = time;
306 }
307}
308
309// Epoch statistics
310////////////////////////////////////////////////////////////////////////////
311void t_ssrQc::newEpoch(const string& staID, char sys, e_type type, const bncTime& time,
312 unsigned updateInt, const QString& prn) {
[11075]313 QString sta = QString::fromStdString(staID);
314 if (!_staStart.contains(sta)) {
315 _staStart[sta] = time;
316 }
317 if (!_staLast.contains(sta) || _staLast[sta] < time) {
318 _staLast[sta] = time;
319 }
[11070]320 t_epoStat& stat = _epoStat[key(staID, sys, type)];
321 if (updateInt < unsigned(ssrUpdateInt.size())) {
322 stat.nominal = ssrUpdateInt[updateInt];
323 }
324 stat.sats.insert(prn);
325 if (!stat.last.valid() || stat.last < time) {
326 if (stat.last.valid()) {
327 stat.intervals.push_back(time - stat.last);
328 }
329 stat.last = time;
330 stat.numEpo += 1;
331 stat.latencies.push_back(currentTime() - time);
332 }
333 else if (time < stat.last) {
334 stat.numOutOfOrder += 1;
335 }
336}
337
338// Broadcast ephemeris the correction refers to
339////////////////////////////////////////////////////////////////////////////
[11075]340void t_ssrQc::checkEph(t_satStat& stat, const QString& prnInt, unsigned iod, const bncTime& time,
341 const string& staID) {
[11073]342 // not before all broadcast ephemerides may have been received
[11075]343 if (time - _staStart[QString::fromStdString(staID)] < EPH_WARMUP) {
[11073]344 return;
345 }
[11070]346 t_eph* ephLast = _ephUser.ephLast(prnInt);
347 t_eph* ephPrev = _ephUser.ephPrev(prnInt);
348 t_eph* eph = 0;
349 if (ephLast && ephLast->IOD() == iod) eph = ephLast;
350 else if (ephPrev && ephPrev->IOD() == iod) eph = ephPrev;
351 if (!ephLast) {
352 stat.noEph += 1;
353 }
354 else if (!eph) {
355 stat.iodUnknown += 1;
356 }
357 else if (eph->checkState() == t_eph::unhealthy || eph->checkState() == t_eph::bad) {
358 stat.ephUnhealthy += 1;
359 }
360}
361
362// Orbit corrections
363////////////////////////////////////////////////////////////////////////////
364void t_ssrQc::slotNewOrbCorrections(QList<t_orbCorr> orbCorrections) {
365 QMutexLocker locker(&_mutex);
366 for (const t_orbCorr& corr : orbCorrections) {
[11081]367 if (!_excluded.contains(QString::fromStdString(corr._staID)) || corr._staID == _reference.toStdString()) {
[11075]368 storeHist(&corr, 0);
369 }
370 if (!selected(corr._staID)) continue;
[11070]371 QString prn = QString::fromStdString(corr._prn.toString());
372 checkInterval(corr._time);
373 newEpoch(corr._staID, corr._prn.system(), ORB, corr._time, corr._updateInt, prn);
374 t_satStat& stat = _satStat[QString::fromStdString(corr._staID) + ' ' + prn];
375 stat.numOrb += 1;
[11075]376 checkEph(stat, QString::fromStdString(corr._prn.toInternalString()), corr._iod, corr._time, corr._staID);
[11070]377 double radial = fabs(corr._xr[0]);
378 double alongCross = max(fabs(corr._xr[1]), fabs(corr._xr[2]));
379 stat.maxRadial = max(stat.maxRadial, radial);
380 stat.maxAlongCross = max(stat.maxAlongCross, alongCross);
381 }
382}
383
384// Clock corrections
385////////////////////////////////////////////////////////////////////////////
386void t_ssrQc::slotNewClkCorrections(QList<t_clkCorr> clkCorrections) {
387 QMutexLocker locker(&_mutex);
388 for (const t_clkCorr& corr : clkCorrections) {
[11081]389 if (!_excluded.contains(QString::fromStdString(corr._staID)) || corr._staID == _reference.toStdString()) {
[11075]390 storeHist(0, &corr);
391 }
392 if (!selected(corr._staID)) continue;
[11070]393 QString prn = QString::fromStdString(corr._prn.toString());
394 checkInterval(corr._time);
395 newEpoch(corr._staID, corr._prn.system(), CLK, corr._time, corr._updateInt, prn);
396 t_satStat& stat = _satStat[QString::fromStdString(corr._staID) + ' ' + prn];
397 stat.numClk += 1;
[11075]398 checkEph(stat, QString::fromStdString(corr._prn.toInternalString()), corr._iod, corr._time, corr._staID);
[11070]399
400 // jump: change between consecutive epochs (same IOD) beyond the clock rate
401 double clk = corr._dClk * t_CST::c;
402 if (stat.lastClkTime.valid() && stat.lastIod == corr._iod) {
403 double dt = corr._time - stat.lastClkTime;
404 int nominal = (corr._updateInt < unsigned(ssrUpdateInt.size())) ? ssrUpdateInt[corr._updateInt] : 0;
405 if (dt > 0.0 && dt <= max(2.0 * nominal, 60.0)) {
406 double jump = fabs(clk - stat.lastClk - corr._dotDClk * t_CST::c * dt);
407 stat.maxJump = max(stat.maxJump, jump);
408 if (jump > MAX_CLK_JUMP) {
409 stat.clkJumps += 1;
410 }
411 }
412 }
413 stat.lastClkTime = corr._time;
414 stat.lastIod = corr._iod;
415 stat.lastClk = clk;
416 }
417}
418
419// Code biases
420////////////////////////////////////////////////////////////////////////////
421void t_ssrQc::slotNewCodeBiases(QList<t_satCodeBias> codeBiases) {
422 QMutexLocker locker(&_mutex);
423 for (const t_satCodeBias& bias : codeBiases) {
[11075]424 bool sel = selected(bias._staID);
[11081]425 if (_excluded.contains(QString::fromStdString(bias._staID)) && bias._staID != _reference.toStdString()) continue;
[11070]426 char sys = bias._prn.system();
427 QString prn = QString::fromStdString(bias._prn.toString());
[11075]428 if (sel) {
429 checkInterval(bias._time);
430 newEpoch(bias._staID, sys, CBIAS, bias._time, bias._updateInt, prn);
431 }
[11070]432 QString staSys = QString("%1 %2").arg(bias._staID.c_str()).arg(sys);
433 QMap<QString, double> values;
434 for (const t_frqCodeBias& frqBias : bias._bias) {
435 QString sig = QString::fromStdString(frqBias._rnxType2ch);
436 _codeSignals[staSys].insert(sig);
437 values[sig] = frqBias._value;
438 }
[11087]439 if (!values.isEmpty()) {
440 _cbLast[QString::fromStdString(bias._staID)][prn] = values;
441 }
[11070]442 t_satStat& stat = _satStat[QString::fromStdString(bias._staID) + ' ' + prn];
443 QString sig1, sig2;
444 t_frequency::type frq1, frq2;
445 if (refSignals(sys, sig1, sig2, frq1, frq2)) {
446 stat.missingRefSig.clear();
447 if (!values.contains(sig1)) stat.missingRefSig.insert("C" + sig1);
448 if (!values.contains(sig2)) stat.missingRefSig.insert("C" + sig2);
449 stat.refBiasOK = stat.missingRefSig.isEmpty();
450 if (stat.refBiasOK) {
451 double channel = 0.0;
452 if (sys == 'R') {
453 t_eph* eph = _ephUser.ephLast(QString::fromStdString(bias._prn.toInternalString()));
454 if (!eph) {
455 stat.refBiasOK = false;
456 continue;
457 }
458 channel = eph->slotNum();
459 }
460 double f1 = t_CST::freq(frq1, channel);
461 double f2 = t_CST::freq(frq2, channel);
462 stat.biasIF = (f1 * f1 * values[sig1] - f2 * f2 * values[sig2]) / (f1 * f1 - f2 * f2);
463 }
464 }
465 }
466}
467
468// Phase biases
469////////////////////////////////////////////////////////////////////////////
470void t_ssrQc::slotNewPhaseBiases(QList<t_satPhaseBias> phaseBiases) {
471 QMutexLocker locker(&_mutex);
472 for (const t_satPhaseBias& bias : phaseBiases) {
[11075]473 if (!selected(bias._staID)) continue;
[11070]474 checkInterval(bias._time);
475 newEpoch(bias._staID, bias._prn.system(), PBIAS, bias._time, bias._updateInt,
476 QString::fromStdString(bias._prn.toString()));
[11073]477 QString staSys = QString("%1 %2").arg(bias._staID.c_str()).arg(bias._prn.system());
478 for (const t_frqPhaseBias& frqBias : bias._bias) {
479 _phaseSignals[staSys].insert(QString::fromStdString(frqBias._rnxType2ch));
480 }
[11085]481 // Yaw angle: new RTCM-SSR has an indicator, old RTCM-SSR and IGS-SSR always
482 // carry the field, providers without attitude information send zeros
483 t_satStat& stat = _satStat[QString::fromStdString(bias._staID) + ' ' +
484 QString::fromStdString(bias._prn.toString())];
485 stat.numPB++;
486 bool yawProvided = (bias._ssrFormat == ssrRtcmNew) ? (bias._satYawInfoInd != 0)
487 : (bias._yaw != 0.0 || bias._yawRate != 0.0);
488 if (yawProvided) {
489 stat.numYaw++;
490 }
[11070]491 }
492}
493
[11087]494// Reference DCBs (bias(base) - bias(signal)) of a satellite: median of the
495// other ACs (one vote per AC, own AC left out, combinations not voting) or
496// the selected reference stream, or from the Bias SINEX file
497////////////////////////////////////////////////////////////////////////////
498bool t_ssrQc::refDcbs(const QString& staID, const QString& prn, const QString& base, bool fromFile,
499 QMap<QString, double>& dcbs, const QString& onlyAC, QMap<QString, int>* numVotes) {
500 dcbs.clear();
501 if (numVotes) {
502 numVotes->clear();
503 }
504 if (fromFile) {
505 if (!_bsxRef) {
506 return false;
507 }
508 char sys = prn[0].toLatin1();
509 t_ssrRefSig::t_pair pair;
510 if (!t_ssrRefSig::get(sys, pair)) {
511 return false;
512 }
513 // the DCBs do not depend on the coefficients (as long as they add up to 1)
514 double f1 = t_CST::freq(pair.frq1, 0.0);
515 double f2 = t_CST::freq(pair.frq2, 0.0);
516 double aIF1 = f1 * f1 / (f1 * f1 - f2 * f2);
517 double aIF2 = -f2 * f2 / (f1 * f1 - f2 * f2);
518 t_satCodeBias satCodeBias;
519 _bsxRef->determineSsrSatCodeBiases(prn, aIF1, aIF2, satCodeBias);
520 QMap<QString, double> osb;
521 for (const t_frqCodeBias& bb : satCodeBias._bias) {
522 osb[QString::fromStdString(bb._rnxType2ch)] = bb._value;
523 }
524 if (!osb.contains(base)) {
525 return false;
526 }
527 for (auto it = osb.constBegin(); it != osb.constEnd(); ++it) {
528 if (it.key() != base) {
529 dcbs[it.key()] = osb[base] - it.value();
530 }
531 }
532 return !dcbs.isEmpty();
533 }
534
535 // per AC: DCBs of its streams
536 QMap<QString, QMap<QString, QVector<double> > > acDcbs; // AC, signal
537 for (auto itS = _cbLast.constBegin(); itS != _cbLast.constEnd(); ++itS) {
538 const QString& sta = itS.key();
539 if (sta == staID) {
540 continue;
541 }
542 if (_reference == "CONSENSUS") {
[11091]543 if (!votes(sta)) {
[11087]544 continue;
545 }
[11091]546 if (onlyAC.isEmpty() ? (acOf(sta) == acOf(staID))
547 : (solOf(sta) != onlyAC || solOf(sta) == solOf(staID))) {
548 continue;
549 }
[11087]550 }
551 else if (sta != _reference) {
552 continue;
553 }
554 if (!itS.value().contains(prn)) {
555 continue;
556 }
557 const QMap<QString, double>& bb = itS.value()[prn];
558 if (!bb.contains(base)) {
559 continue;
560 }
561 for (auto it = bb.constBegin(); it != bb.constEnd(); ++it) {
562 if (it.key() != base) {
[11091]563 acDcbs[solOf(sta)][it.key()].append(bb[base] - it.value());
[11087]564 }
565 }
566 }
567 QMap<QString, QVector<double> > votesPerSig;
568 for (auto itA = acDcbs.constBegin(); itA != acDcbs.constEnd(); ++itA) {
569 for (auto it = itA.value().constBegin(); it != itA.value().constEnd(); ++it) {
570 votesPerSig[it.key()].append(median(it.value()));
571 }
572 }
573 for (auto it = votesPerSig.constBegin(); it != votesPerSig.constEnd(); ++it) {
574 dcbs[it.key()] = median(it.value());
575 if (numVotes) {
576 (*numVotes)[it.key()] = it.value().size();
577 }
578 }
579 return !dcbs.isEmpty();
580}
581
582// Code bias check: the DCBs of the stream (relative to the first reference
583// signal, otherwise to another common signal) compared with a reference;
584// DCBs do not depend on the clock datum. A sign opposite to the reference
585// for most DCBs indicates the SINEX instead of the SSR sign convention.
586// References: the Bias SINEX file, the selected reference stream, or each
587// other AC separately (a median of two ACs cannot decide which one is wrong)
588////////////////////////////////////////////////////////////////////////////
589void t_ssrQc::compareCodeBiases(const QString& staID, char sys, QStringList& lines) {
590 const double MIN_DCB = 0.3; // [m] DCBs used for the sign check
591 const double MIN_DEV = 0.5; // [m] deviating DCBs: > max(MIN_DEV, DEV_FACTOR x median)
592 const int MIN_VOTE = 3; // ACs needed for a consensus DCB (deviating satellites)
593 if (!_cbLast.contains(staID)) {
594 return;
595 }
596 t_ssrRefSig::t_pair pair;
597 if (!t_ssrRefSig::get(sys, pair)) {
598 return;
599 }
600 const QString ref1 = QString::fromStdString(pair.sig1());
601
602 class t_cmp {
603 public:
604 unsigned numSat = 0, numSign = 0, numOpp = 0;
605 double sumSqr = 0.0;
606 QVector<double> absDiffs;
607 QMap<QString, QPair<QString, double> > maxDiff; // PRN: signal, difference
608 QSet<QString> bases;
609 bool opposite() const {return numSign >= 5 && numOpp > numSign / 2;}
610 double rms() const {return absDiffs.isEmpty() ? 0.0 : sqrt(sumSqr / absDiffs.size());}
611 };
612 // mode 0: file, 1: reference stream or median of the other ACs, 2: one other AC
613 auto compare = [&](int mode, const QString& onlyAC) {
614 t_cmp cc;
615 for (auto itP = _cbLast[staID].constBegin(); itP != _cbLast[staID].constEnd(); ++itP) {
616 const QString& prn = itP.key();
617 if (prn[0].toLatin1() != sys) {
618 continue;
619 }
620 const QMap<QString, double>& bb = itP.value();
621 QStringList candidates = QStringList() << ref1 << bb.keys();
622 QMap<QString, double> dcbs;
623 QMap<QString, int> numVotes;
624 QString base;
625 for (const QString& cand : candidates) {
626 if (bb.contains(cand) && refDcbs(staID, prn, cand, mode == 0, dcbs, onlyAC, &numVotes)) {
627 base = cand;
628 break;
629 }
630 }
631 if (base.isEmpty()) {
632 continue;
633 }
634 bool used = false;
635 for (auto it = dcbs.constBegin(); it != dcbs.constEnd(); ++it) {
636 if (!bb.contains(it.key())) {
637 continue;
638 }
639 if (mode == 1 && _reference == "CONSENSUS" && numVotes.value(it.key()) < MIN_VOTE) {
640 continue;
641 }
642 double dcb = bb[base] - bb[it.key()];
643 double diff = dcb - it.value();
644 used = true;
645 cc.sumSqr += diff * diff;
646 cc.absDiffs.append(fabs(diff));
647 if (fabs(diff) > fabs(cc.maxDiff.value(prn).second)) {
648 cc.maxDiff[prn] = qMakePair("C" + it.key(), diff);
649 }
650 if (fabs(it.value()) >= MIN_DCB) {
651 cc.numSign++;
652 if (dcb * it.value() < 0.0) {
653 cc.numOpp++;
654 }
655 }
656 }
657 if (used) {
658 cc.numSat++;
659 cc.bases.insert("C" + base);
660 }
661 }
662 return cc;
663 };
664 auto baseStr = [](const t_cmp& cc) {
665 QStringList ll = cc.bases.values();
666 ll.sort();
667 return ll.join("/");
668 };
669 auto deviating = [&](const t_cmp& cc, const QString& refName) {
670 if (cc.absDiffs.isEmpty() || cc.opposite()) {
671 return;
672 }
673 double limit = std::max(MIN_DEV, DEV_FACTOR * median(cc.absDiffs));
674 QStringList devs;
675 for (auto it = cc.maxDiff.constBegin(); it != cc.maxDiff.constEnd(); ++it) {
676 if (fabs(it.value().second) > limit) {
677 devs << QString("%1(%2 %3)").arg(it.key()).arg(it.value().first).arg(it.value().second, 0, 'f', 2);
678 }
679 }
680 if (!devs.isEmpty()) {
681 lines << QString("satellites with DCBs deviating from %1 [m]: %2").arg(refName).arg(devs.join(" "));
682 }
683 };
684
685 // Bias SINEX file or the selected reference stream
686 for (int mode = 0; mode < 2; mode++) {
687 if ((mode == 0 && !_bsxRef) || (mode == 1 && _reference == "CONSENSUS")) {
688 continue;
689 }
690 QString refName = (mode == 0) ? QString("Bias SINEX file %1").arg(_bsxName) : _reference;
691 t_cmp cc = compare(mode, QString());
692 if (cc.absDiffs.isEmpty()) {
693 continue;
694 }
695 lines << QString("code biases compared with %1 (DCBs to %2, %3 satellites, %4 DCBs): RMS of the differences "
696 "%5 m, opposite sign for %6 of %7 DCBs (|DCB| >= %8 m)")
697 .arg(refName).arg(baseStr(cc)).arg(cc.numSat).arg(cc.absDiffs.size())
698 .arg(cc.rms(), 0, 'f', 3).arg(cc.numOpp).arg(cc.numSign).arg(MIN_DCB, 0, 'f', 1);
699 if (cc.opposite()) {
700 lines << QString("code biases with the opposite sign of %1 for %2 of %3 DCBs - "
701 "SINEX sign convention instead of the SSR one?").arg(refName).arg(cc.numOpp).arg(cc.numSign);
702 }
703 deviating(cc, refName);
704 }
705
706 // Each other AC separately, and the median of the other ACs for deviating satellites
707 if (_reference == "CONSENSUS") {
708 QSet<QString> acs;
709 for (auto itS = _cbLast.constBegin(); itS != _cbLast.constEnd(); ++itS) {
[11091]710 if (itS.key() != staID && votes(itS.key()) && solOf(itS.key()) != solOf(staID)) {
711 acs.insert(solOf(itS.key()));
[11087]712 }
713 }
714 QStringList acList = acs.values();
715 acList.sort();
716 QStringList items;
717 QStringList oppACs;
718 int numACs = 0;
719 QSet<QString> bases;
720 for (const QString& ac : acList) {
721 t_cmp cc = compare(2, ac);
722 if (cc.absDiffs.isEmpty()) {
723 continue;
724 }
725 bases.unite(cc.bases);
726 items << QString("%1 RMS %2 m, opposite sign %3/%4").arg(ac).arg(cc.rms(), 0, 'f', 2)
727 .arg(cc.numOpp).arg(cc.numSign);
[11091]728 // the sign rule counts the solutions of other ACs only
729 if (acOfSol(ac) != acOf(staID)) {
730 numACs++;
731 if (cc.opposite()) {
732 oppACs << ac;
733 }
[11087]734 }
735 }
[11091]736 if (!items.isEmpty()) {
[11087]737 QStringList ll = bases.values();
738 ll.sort();
[11091]739 lines << QString("code biases compared with the other solutions (DCBs to %1): %2").arg(ll.join("/")).arg(items.join("; "));
[11087]740 if (numACs >= 2 && oppACs.size() > numACs / 2) {
[11091]741 lines << QString("code biases with the opposite sign of %1 of %2 solutions of other ACs (%3) - "
[11087]742 "SINEX sign convention instead of the SSR one?")
743 .arg(oppACs.size()).arg(numACs).arg(oppACs.join(" "));
744 }
[11091]745 deviating(compare(1, QString()), QString("the median of the other ACs (at least %1 solutions)").arg(MIN_VOTE));
[11087]746 }
747 }
748}
749
[11075]750// Reference point of the orbits of a stream: APC (SSRA...), CoM (SSRC...);
[11079]751// the combined stream INTERNAL refers to the APC
[11075]752////////////////////////////////////////////////////////////////////////////
753char t_ssrQc::refPoint(const QString& staID) const {
[11079]754 if (staID == "INTERNAL") {
755 return 'A'; // the combined corrections always refer to the APC
756 }
757 const QString& sta = staID;
[11075]758 if (sta.startsWith("SSRA")) return 'A';
759 if (sta.startsWith("SSRC")) return 'C';
760 return '?';
761}
762
763// Keep the recent orbit and clock corrections of a stream
764////////////////////////////////////////////////////////////////////////////
765void t_ssrQc::storeHist(const t_orbCorr* orb, const t_clkCorr* clk) {
766 if (orb) {
767 QMap<double, t_orbCorr>& hist = _orbHist[QString::fromStdString(orb->_staID)]
768 [QString::fromStdString(orb->_prn.toInternalString())];
769 hist[tKey(orb->_time)] = *orb;
770 while (!hist.isEmpty() && hist.firstKey() < tKey(orb->_time) - HIST_LENGTH) {
771 hist.erase(hist.begin());
772 }
773 }
774 if (clk) {
775 QMap<double, t_clkCorr>& hist = _clkHist[QString::fromStdString(clk->_staID)]
776 [QString::fromStdString(clk->_prn.toInternalString())];
777 hist[tKey(clk->_time)] = *clk;
778 while (!hist.isEmpty() && hist.firstKey() < tKey(clk->_time) - HIST_LENGTH) {
779 hist.erase(hist.begin());
780 }
781 }
782}
783
784// Compare the orbits and clocks of all streams at epoch tt
785////////////////////////////////////////////////////////////////////////////
786void t_ssrQc::compareEpoch(const bncTime& tt) {
787 double tk = tKey(tt);
788
789 // Satellite positions and clocks of all streams (latest corrections <= tt)
790 QMap<QString, QMap<QString, t_satCrd> > crd; // stream, PRN (external)
791 QMapIterator<QString, QMap<QString, QMap<double, t_clkCorr> > > itSta(_clkHist);
792 while (itSta.hasNext()) {
793 itSta.next();
794 const QString& staID = itSta.key();
795 QMapIterator<QString, QMap<double, t_clkCorr> > itPrn(itSta.value());
796 while (itPrn.hasNext()) {
797 itPrn.next();
798 const QString& prnInt = itPrn.key();
799 QMap<double, t_clkCorr>::const_iterator itC = itPrn.value().upperBound(tk);
800 if (itC == itPrn.value().constBegin()) {
801 continue;
802 }
803 const t_clkCorr& clk = (--itC).value();
804 const QMap<double, t_orbCorr>& orbHist = _orbHist[staID][prnInt];
805 QMap<double, t_orbCorr>::const_iterator itO = orbHist.upperBound(tk);
806 if (itO == orbHist.constBegin()) {
807 continue;
808 }
809 const t_orbCorr& orb = (--itO).value();
810 t_eph* eph = 0;
811 t_eph* ephLast = _ephUser.ephLast(prnInt);
812 t_eph* ephPrev = _ephUser.ephPrev(prnInt);
813 if (ephLast && ephLast->IOD() == orb._iod) eph = ephLast;
814 else if (ephPrev && ephPrev->IOD() == orb._iod) eph = ephPrev;
815 if (!eph || clk._iod != orb._iod) {
816 continue;
817 }
818 eph->setOrbCorr(&orb);
819 eph->setClkCorr(&clk);
820 ColumnVector xc(6), vv(3);
821 if (eph->getCrd(tt, xc, vv, true) != success) {
822 continue;
823 }
824 t_satCrd& sc = crd[staID][QString::fromStdString(clk._prn.toString())];
825 sc.xx = xc.Rows(1,3);
826 sc.vv = vv;
827 sc.clk = xc(4) * t_CST::c;
828 sc.refPoint = refPoint(staID);
829 // clock referred to the reference signals (as in the combination):
830 // + B_IF of the stream's code biases; the combination (INTERNAL)
831 // refers to them already
832 sc.clkOK = (staID == "INTERNAL");
833 QString satKey = staID + ' ' + QString::fromStdString(clk._prn.toString());
834 if (!sc.clkOK && _satStat.contains(satKey) && _satStat[satKey].refBiasOK) {
835 sc.clk += _satStat[satKey].biasIF;
836 sc.clkOK = true;
837 }
838 }
839 }
840
841 // Satellites per system
842 QMap<char, QSet<QString> > satsSys;
843 QMapIterator<QString, QMap<QString, t_satCrd> > itCrd(crd);
844 while (itCrd.hasNext()) {
845 itCrd.next();
846 for (const QString& prn : itCrd.value().keys()) {
847 satsSys[prn[0].toLatin1()].insert(prn);
848 }
849 }
850
851 QMapIterator<char, QSet<QString> > itSys(satsSys);
852 while (itSys.hasNext()) {
853 itSys.next();
854 const QSet<QString>& sats = itSys.value();
855 QStringList staList;
856 for (const QString& staID : crd.keys()) {
857 for (const QString& prn : sats) {
858 if (crd[staID].contains(prn)) {
859 staList << staID;
860 break;
861 }
862 }
863 }
864
[11081]865 // Consensus: one value per AC (median of its streams), so that an AC
866 // with several streams (formats, APC/CoM) has one vote; the streams of
867 // the AC compared are left out (leave-one-AC-out)
868 auto acClocks = [&](const QString& prn, const QMap<QString, double>& off, bool useOff) {
869 QMap<QString, QVector<double> > perAC;
870 for (const QString& oo : staList) {
871 if (!votes(oo) || !crd[oo].contains(prn) || !crd[oo][prn].clkOK) continue;
872 if (useOff && !off.contains(oo)) continue;
[11091]873 perAC[solOf(oo)] << crd[oo][prn].clk - (useOff ? off[oo] : 0.0);
[11081]874 }
875 QMap<QString, double> res;
876 for (const QString& ac : perAC.keys()) res[ac] = median(perAC[ac]);
877 return res;
878 };
879
880 // Clock datum: offset of each stream w.r.t. the per-satellite consensus
881 // of all ACs (or w.r.t. the reference stream)
[11075]882 QMap<QString, double> offset;
[11081]883 QMap<QString, double> noOff;
884 QMap<QString, QMap<QString, double> > rawAC; // PRN, AC
885 if (_reference == "CONSENSUS") {
886 for (const QString& prn : sats) rawAC[prn] = acClocks(prn, noOff, false);
887 }
[11075]888 for (const QString& staID : staList) {
889 QVector<double> diffs;
890 for (const QString& prn : sats) {
891 if (!crd[staID].contains(prn) || !crd[staID][prn].clkOK) continue;
892 if (_reference == "CONSENSUS") {
[11081]893 if (rawAC[prn].size() >= 3) diffs << crd[staID][prn].clk - median(rawAC[prn].values().toVector());
[11075]894 }
895 else if (crd.value(_reference).contains(prn) && crd[_reference][prn].clkOK) {
896 diffs << crd[staID][prn].clk - crd[_reference][prn].clk;
897 }
898 }
899 if (!diffs.isEmpty()) {
900 offset[staID] = median(diffs);
901 }
902 }
[11081]903 QMap<QString, QMap<QString, double> > alignedAC; // PRN, AC
904 if (_reference == "CONSENSUS") {
905 for (const QString& prn : sats) alignedAC[prn] = acClocks(prn, offset, true);
906 }
[11075]907
908 // Differences of each selected stream to its reference
909 for (const QString& staID : staList) {
910 if (!selected(staID.toStdString()) || staID == _reference) {
911 continue;
912 }
[11081]913 const QString ownAC = acOf(staID);
[11075]914 QMap<QString, double> dClk;
915 QMap<QString, ColumnVector> dRac;
916 for (const QString& prn : sats) {
917 if (!crd[staID].contains(prn)) continue;
918 const t_satCrd& sc = crd[staID][prn];
[11081]919 QVector<double> clks, xs[3];
[11075]920 if (_reference == "CONSENSUS") {
[11081]921 QMapIterator<QString, double> itA(alignedAC[prn]);
922 while (itA.hasNext()) {
923 itA.next();
[11091]924 if (acOfSol(itA.key()) != ownAC) clks << itA.value();
[11075]925 }
[11081]926 // orbits: per AC the median of its streams with the same reference point
927 QMap<QString, QVector<double> > perAC[3];
[11075]928 for (const QString& oo : staList) {
[11081]929 if (!votes(oo) || acOf(oo) == ownAC || !crd[oo].contains(prn)) continue;
930 const t_satCrd& rc = crd[oo][prn];
931 if (rc.refPoint != sc.refPoint || sc.refPoint == '?') continue;
[11091]932 for (int ii = 0; ii < 3; ii++) perAC[ii][solOf(oo)] << rc.xx[ii];
[11075]933 }
[11081]934 for (const QString& ac : perAC[0].keys()) {
935 for (int ii = 0; ii < 3; ii++) xs[ii] << median(perAC[ii][ac]);
936 }
937 if (clks.size() < 2) clks.clear();
938 if (xs[0].size() < 2) for (int ii = 0; ii < 3; ii++) xs[ii].clear();
[11075]939 }
940 else {
941 if (!crd.value(_reference).contains(prn)) continue;
[11081]942 const t_satCrd& rc = crd[_reference][prn];
943 if (rc.clkOK) clks << rc.clk;
[11075]944 if (rc.refPoint == sc.refPoint && sc.refPoint != '?') {
945 for (int ii = 0; ii < 3; ii++) xs[ii] << rc.xx[ii];
946 }
947 }
[11081]948 if (sc.clkOK && offset.contains(staID) && !clks.isEmpty()) {
[11075]949 dClk[prn] = sc.clk - offset[staID] - median(clks);
950 }
951 else if (!sc.clkOK) {
952 _cmpStat[staID][prn].clkNotReferred += 1;
953 }
[11081]954 if (!xs[0].isEmpty()) {
[11075]955 ColumnVector dx(3);
956 for (int ii = 0; ii < 3; ii++) dx[ii] = sc.xx[ii] - median(xs[ii]);
957 ColumnVector er = sc.xx / sc.xx.NormFrobenius();
958 ColumnVector ec = crossproduct(sc.xx, sc.vv);
959 ec /= ec.NormFrobenius();
960 ColumnVector ea = crossproduct(ec, er);
961 ColumnVector rac(3);
962 rac[0] = DotProduct(dx, er);
963 rac[1] = DotProduct(dx, ea);
964 rac[2] = DotProduct(dx, ec);
965 dRac[prn] = rac;
966 }
967 }
968 if (dClk.isEmpty() && dRac.isEmpty()) {
969 continue;
970 }
971 // remaining common clock offset of this stream and epoch
972 double off = dClk.isEmpty() ? 0.0 : median(dClk.values().toVector());
973 _cmpEpochs[staID] += 1;
974 QSet<QString> prns;
975 for (const QString& prn : dClk.keys()) prns.insert(prn);
976 for (const QString& prn : dRac.keys()) prns.insert(prn);
977 for (const QString& prn : prns) {
978 t_cmpStat& stat = _cmpStat[staID][prn];
979 if (dRac.contains(prn)) {
980 const ColumnVector& rac = dRac[prn];
981 stat.numOrb += 1;
982 stat.sR += rac[0] * rac[0];
983 stat.sA += rac[1] * rac[1];
984 stat.sC += rac[2] * rac[2];
985 }
986 if (dClk.contains(prn)) {
987 double dc = dClk[prn] - off;
988 stat.numClk += 1;
989 stat.sClk += dc * dc;
990 stat.mClk += dc;
991 if (dRac.contains(prn)) {
992 double sis = dRac[prn][0] - dc;
993 stat.numSis += 1;
994 stat.sSis += sis * sis;
995 stat.mSis += sis;
996 }
997 }
998 }
999 }
1000 }
1001}
1002
[11117]1003// Ionospheric model (VTEC)
1004////////////////////////////////////////////////////////////////////////////
1005void t_ssrQc::slotNewTec(t_vTec vTec) {
1006 QMutexLocker locker(&_mutex);
1007 if (vTec._layers.empty()) {
1008 return;
1009 }
1010 QString sta = QString::fromStdString(vTec._staID);
1011 if (!_excluded.contains(sta) || sta == _reference) {
1012 QMap<double, t_vTec>& hist = _vtecHist[sta];
1013 hist[tKey(vTec._time)] = vTec;
1014 while (!hist.isEmpty() && tKey(vTec._time) - hist.firstKey() > VTEC_HIST) {
1015 hist.erase(hist.begin());
1016 }
1017 }
1018 if (selected(vTec._staID)) {
1019 newEpoch(vTec._staID, 'V', VTEC, vTec._time, vTec._updateInt, "VTEC");
1020 QStringList layers;
1021 for (const t_vTecLayer& layer : vTec._layers) {
1022 layers << QString("%1 km (degree/order %2/%3)").arg(layer._height / 1000.0, 0, 'f', 0)
1023 .arg(layer._C.Nrows() - 1).arg(layer._C.Ncols() - 1);
1024 }
1025 _vtecStat[sta].model = QString("%1 layer(s): %2").arg(vTec._layers.size()).arg(layers.join(", "));
1026 }
1027 checkInterval(vTec._time);
1028}
1029
1030// VTEC [TECU] of a model at a geocentric latitude and longitude [rad] and
1031// epoch: spherical harmonics in the sun-fixed frame as used in PPP, sum of
1032// the layers (negative layer values set to zero as in PPP)
1033////////////////////////////////////////////////////////////////////////////
1034double t_ssrQc::vtecAt(const t_vTec& vTec, double lat, double lon, const bncTime& tt, bool& negative) {
1035 double epoch = fmod(tt.gpssec(), 86400.0);
1036 double lonS = fmod(lon + (epoch - 50400.0) * M_PI / 43200.0, 2.0 * M_PI);
1037 double vtec = 0.0;
1038 negative = false;
1039 for (const t_vTecLayer& layer : vTec._layers) {
1040 int N = layer._C.Nrows() - 1;
1041 int M = layer._C.Ncols() - 1;
1042 double vv = 0.0;
1043 for (int n = 0; n <= N; n++) {
1044 for (int m = 0; m <= min(n, M); m++) {
1045 double pnm = associatedLegendreFunction(n, m, sin(lat));
1046 double fac = (m == 0) ? sqrt(2.0 * n + 1.0)
1047 : sqrt(2.0 * (2.0 * n + 1.0) * factorial(n - m) / factorial(n + m));
1048 vv += (layer._C[n][m] * cos(m * lonS) + layer._S[n][m] * sin(m * lonS)) * pnm * fac;
1049 }
1050 }
1051 if (vv < 0.0) {
1052 negative = true;
1053 vv = 0.0;
1054 }
1055 vtec += vv;
1056 }
1057 return vtec;
1058}
1059
1060// VTEC comparison: each model (latest one not older than twice its update
1061// interval) evaluated on a global grid and compared with the median of the
1062// other AC solutions (one vote per solution, own AC left out, combination
1063// products not voting) or with the reference stream
1064////////////////////////////////////////////////////////////////////////////
1065void t_ssrQc::compareVtec(const bncTime& tt) {
1066 double tk = tKey(tt);
1067
1068 QMap<QString, const t_vTec*> models;
1069 QMapIterator<QString, QMap<double, t_vTec> > itH(_vtecHist);
1070 while (itH.hasNext()) {
1071 itH.next();
1072 QMap<double, t_vTec>::const_iterator it = itH.value().upperBound(tk);
1073 if (it == itH.value().constBegin()) {
1074 continue;
1075 }
1076 --it;
1077 const t_vTec& vTec = it.value();
1078 double interval = (vTec._updateInt < unsigned(ssrUpdateInt.size()) && ssrUpdateInt[vTec._updateInt] > 0)
1079 ? ssrUpdateInt[vTec._updateInt] : VTEC_CMP_SAMPL;
1080 double maxAge = min(3600.0, max(120.0, 2.0 * interval));
1081 if (tk - it.key() <= maxAge) {
1082 models[itH.key()] = &vTec;
1083 }
1084 }
1085 if (models.isEmpty()) {
1086 return;
1087 }
1088
1089 // grid values of all models
1090 QVector<QPair<double, double> > grid;
1091 for (double lat = -80.0; lat <= 80.0 + 1e-6; lat += VTEC_GRID_LAT) {
1092 for (double lon = -180.0; lon < 180.0 - 1e-6; lon += VTEC_GRID_LON) {
1093 grid << QPair<double, double>(lat * M_PI / 180.0, lon * M_PI / 180.0);
1094 }
1095 }
1096 QMap<QString, QVector<double> > val;
1097 QMapIterator<QString, const t_vTec*> itM(models);
1098 while (itM.hasNext()) {
1099 itM.next();
1100 QVector<double>& vv = val[itM.key()];
1101 vv.resize(grid.size());
1102 bool selectedSta = selected(itM.key().toStdString());
1103 t_vtecStat* st = selectedSta ? &_vtecStat[itM.key()] : 0;
1104 for (int ii = 0; ii < grid.size(); ii++) {
1105 bool negative = false;
1106 vv[ii] = vtecAt(*itM.value(), grid[ii].first, grid[ii].second, tt, negative);
1107 if (st) {
1108 if (st->numGrid == 0 || vv[ii] < st->minVal) st->minVal = vv[ii];
1109 if (st->numGrid == 0 || vv[ii] > st->maxVal) st->maxVal = vv[ii];
1110 st->numGrid += 1;
1111 if (negative) st->numNeg += 1;
1112 }
1113 }
1114 }
1115
1116 // comparison
1117 for (const QString& staID : models.keys()) {
1118 if (!selected(staID.toStdString())) {
1119 continue;
1120 }
1121 QMap<QString, QStringList> perSol; // solution: streams
1122 for (const QString& oo : models.keys()) {
1123 if (oo == staID) continue;
1124 if (_reference == "CONSENSUS") {
1125 if (!votes(oo) || acOf(oo) == acOf(staID)) continue;
1126 }
1127 else if (oo != _reference) {
1128 continue;
1129 }
1130 perSol[solOf(oo)] << oo;
1131 }
1132 if (perSol.isEmpty()) {
1133 continue;
1134 }
1135 t_vtecStat& st = _vtecStat[staID];
1136 st.numCmp += 1;
1137 st.numSol += perSol.size();
1138 for (int ii = 0; ii < grid.size(); ii++) {
1139 QVector<double> solVal;
1140 for (const QStringList& streams : perSol) {
1141 QVector<double> vv;
1142 for (const QString& oo : streams) vv << val[oo][ii];
1143 solVal << median(vv);
1144 }
1145 double diff = val[staID][ii] - median(solVal);
1146 st.numDiff += 1;
1147 st.sDiff += diff;
1148 st.sDiff2 += diff * diff;
1149 st.maxAbs = max(st.maxAbs, fabs(diff));
1150 }
1151 }
1152}
1153
1154
[11070]1155// Write the report of the current interval and reset the statistics
1156////////////////////////////////////////////////////////////////////////////
1157void t_ssrQc::writeReport(bool final) {
[11075]1158 if (_epoStat.isEmpty()) {
[11070]1159 return;
1160 }
1161 bncTime endTime = final ? _lastTime : _intervalStart + _interval;
1162
1163 // Healthy and current broadcast ephemerides per system
1164 QMap<char, QSet<QString> > ephSats;
1165 for (const QString& prnInt : _ephUser.prnList()) {
1166 t_eph* eph = _ephUser.ephLast(prnInt);
1167 if (eph && eph->checkState() == t_eph::ok && !outDatedBcep(eph, endTime)) {
1168 ephSats[eph->prn().system()].insert(QString::fromStdString(eph->prn().toString()));
1169 }
1170 }
1171
1172 // Streams and systems
1173 QMap<QString, QSet<char> > streams;
1174 QMapIterator<QString, t_epoStat> itEpo(_epoStat);
1175 while (itEpo.hasNext()) {
1176 itEpo.next();
1177 QStringList hlp = itEpo.key().split(' ');
1178 streams[hlp[0]].insert(hlp[1][0].toLatin1());
1179 }
1180
1181 QMapIterator<QString, QSet<char> > itSta(streams);
1182 while (itSta.hasNext()) {
1183 itSta.next();
1184 const QString& staID = itSta.key();
1185 QList<char> systems = itSta.value().values();
1186 std::sort(systems.begin(), systems.end());
[11075]1187 ofstream& out = streamOut(staID, _intervalStart);
1188 if (final) {
1189 endTime = _staLast.value(staID, _lastTime);
[11085]1190 if (endTime < _intervalStart) { // stream without data in the last interval
1191 endTime = _lastTime;
1192 }
[11075]1193 }
1194 out << endl << "=== SSR QC " << staID.toStdString() << ' '
1195 << _intervalStart.datestr() << ' ' << _intervalStart.timestr(0)
1196 << " - " << endTime.datestr() << ' ' << endTime.timestr(0)
1197 << (final ? " (end of data)" : "") << " ===" << endl
1198 << " Sys Type Epochs Interval nom/med/max [s] Gaps Out-of-order"
[11070]1199 << " Latency med/max [s] Sats" << endl;
1200 for (char sys : systems) {
[11117]1201 for (int type = ORB; type <= VTEC; type++) {
[11070]1202 QString kk = QString("%1 %2 %3").arg(staID).arg(sys).arg(type);
1203 if (!_epoStat.contains(kk)) {
1204 continue;
1205 }
1206 const t_epoStat& stat = _epoStat[kk];
1207 double medInt = median(stat.intervals);
1208 double maxInt = stat.intervals.isEmpty() ? 0.0 :
1209 *std::max_element(stat.intervals.begin(), stat.intervals.end());
1210 double ref = max(double(stat.nominal), medInt);
1211 unsigned gaps = 0;
1212 for (double dt : stat.intervals) {
1213 if (ref > 0.0 && dt > 2.0 * ref) {
1214 ++gaps;
1215 }
1216 }
1217 double maxLat = stat.latencies.isEmpty() ? 0.0 :
1218 *std::max_element(stat.latencies.begin(), stat.latencies.end());
[11075]1219 out << " " << sys << " " << left << setw(10) << TYPE_NAME[type] << right
[11070]1220 << setw(8) << stat.numEpo
1221 << setw(10) << stat.nominal << " /" << setw(6) << setprecision(1) << fixed << medInt
1222 << " /" << setw(7) << maxInt
1223 << setw(6) << gaps << setw(14) << stat.numOutOfOrder
1224 << setw(10) << median(stat.latencies) << " /" << setw(7) << maxLat
1225 << setw(7) << stat.sats.size() << endl;
1226 }
1227 }
1228
1229 // Per system: completeness, ephemeris, biases, plausibility
1230 for (char sys : systems) {
[11117]1231 // Ionosphere (VTEC): model, values on the grid, comparison
1232 if (sys == 'V') {
1233 QStringList lines;
1234 QString kk = QString("%1 %2 %3").arg(staID).arg(sys).arg(int(VTEC));
1235 if (_epoStat.contains(kk)) {
1236 const t_epoStat& stat = _epoStat[kk];
1237 double medInt = median(stat.intervals);
1238 if (stat.nominal > 0 && stat.intervals.size() > 1 &&
1239 (medInt > 1.5 * stat.nominal || medInt < stat.nominal / 1.5)) {
1240 lines << QString("VTEC: declared update interval %1 s, actual %2 s")
1241 .arg(stat.nominal).arg(medInt, 0, 'f', 1);
1242 }
1243 }
1244 if (_vtecStat.contains(staID)) {
1245 const t_vtecStat& vs = _vtecStat[staID];
1246 if (!vs.model.isEmpty()) {
1247 lines << "VTEC model: " + vs.model;
1248 }
1249 if (vs.numGrid) {
1250 lines << QString("VTEC on a global %1 x %2 deg grid: %3 ... %4 TECU, negative (set to zero) %5 %")
1251 .arg(VTEC_GRID_LAT, 0, 'f', 0).arg(VTEC_GRID_LON, 0, 'f', 0)
1252 .arg(vs.minVal, 0, 'f', 1).arg(vs.maxVal, 0, 'f', 1)
1253 .arg(100.0 * vs.numNeg / vs.numGrid, 0, 'f', 1);
1254 }
1255 if (vs.numDiff) {
1256 QString refText = (_reference == "CONSENSUS")
1257 ? QString("consensus of the other ACs (%1 solutions on average)")
1258 .arg(double(vs.numSol) / vs.numCmp, 0, 'f', 1)
1259 : QString("stream %1").arg(_reference);
1260 double mean = vs.sDiff / vs.numDiff;
1261 double rms = sqrt(vs.sDiff2 / vs.numDiff);
1262 lines << QString("VTEC comparison with %1, %2 epochs: mean %3 TECU, RMS %4 TECU, max |diff| %5 TECU")
1263 .arg(refText).arg(vs.numCmp).arg(mean, 0, 'f', 2).arg(rms, 0, 'f', 2)
1264 .arg(vs.maxAbs, 0, 'f', 1);
1265 }
1266 else {
1267 lines << "VTEC not compared: no other model available";
1268 }
1269 }
1270 for (const QString& line : lines) {
1271 out << " " << sys << " " << line.toStdString() << endl;
1272 }
1273 continue;
1274 }
[11070]1275 QMap<QString, unsigned> missOrb, missClk, missCB, missPB, iodUnknown, unhealthy, noEph,
[11085]1276 jumps, missRef, noYaw, yawPartly;
1277 unsigned numPB = 0, numYaw = 0, yawSats = 0;
[11070]1278 double maxRadial = 0.0, maxAlongCross = 0.0;
1279 QString maxRadialPrn, maxAlongCrossPrn;
1280 QVector<double> biasIF;
[11119]1281 double maxBiasIF = 0.0;
1282 QString biasIFPrn;
[11070]1283 const QSet<QString>& ephs = ephSats[sys];
1284 for (int type = ORB; type <= PBIAS; type++) {
1285 QString kk = QString("%1 %2 %3").arg(staID).arg(sys).arg(type);
1286 if (!_epoStat.contains(kk)) {
1287 continue;
1288 }
1289 QMap<QString, unsigned>& miss = (type == ORB) ? missOrb : (type == CLK) ? missClk :
1290 (type == CBIAS) ? missCB : missPB;
1291 for (const QString& prn : ephs) {
1292 if (!_epoStat[kk].sats.contains(prn)) {
1293 miss[prn] = 0;
1294 }
1295 }
1296 }
1297 QMapIterator<QString, t_satStat> itSat(_satStat);
1298 while (itSat.hasNext()) {
1299 itSat.next();
1300 QStringList hlp = itSat.key().split(' ');
1301 if (hlp[0] != staID || hlp[1][0].toLatin1() != sys) {
1302 continue;
1303 }
1304 const QString& prn = hlp[1];
1305 const t_satStat& stat = itSat.value();
1306 if (stat.iodUnknown) iodUnknown[prn] = stat.iodUnknown;
1307 if (stat.ephUnhealthy) unhealthy[prn] = stat.ephUnhealthy;
1308 if (stat.noEph) noEph[prn] = stat.noEph;
1309 if (stat.clkJumps) jumps[prn] = stat.clkJumps;
1310 if (stat.maxRadial > maxRadial) {
1311 maxRadial = stat.maxRadial;
1312 maxRadialPrn = prn;
1313 }
1314 if (stat.maxAlongCross > maxAlongCross) {
1315 maxAlongCross = stat.maxAlongCross;
1316 maxAlongCrossPrn = prn;
1317 }
1318 if (!stat.missingRefSig.isEmpty()) {
1319 QStringList sigs = stat.missingRefSig.values();
1320 sigs.sort();
1321 missRef[prn + "[" + sigs.join("/") + "]"] = 0;
1322 }
1323 if (stat.refBiasOK) {
1324 biasIF.push_back(stat.biasIF);
[11119]1325 if (biasIFPrn.isEmpty() || fabs(stat.biasIF) > maxBiasIF) {
1326 maxBiasIF = fabs(stat.biasIF);
1327 biasIFPrn = prn;
1328 }
[11070]1329 }
[11085]1330 if (stat.numPB) {
1331 numPB += stat.numPB;
1332 numYaw += stat.numYaw;
1333 if (stat.numYaw) {
1334 yawSats++;
1335 if (stat.numYaw < stat.numPB) {
1336 yawPartly[prn] = stat.numPB - stat.numYaw;
1337 }
1338 }
1339 else {
1340 noYaw[prn] = 0;
1341 }
1342 }
[11070]1343 }
1344 // only findings are listed
1345 QStringList lines;
1346 for (int type = ORB; type <= PBIAS; type++) {
1347 QString kk = QString("%1 %2 %3").arg(staID).arg(sys).arg(type);
1348 if (_epoStat.contains(kk)) {
1349 const t_epoStat& stat = _epoStat[kk];
1350 double medInt = median(stat.intervals);
1351 if (stat.nominal > 0 && stat.intervals.size() > 1 &&
1352 (medInt > 1.5 * stat.nominal || medInt < stat.nominal / 1.5)) {
1353 lines << QString("%1: declared update interval %2 s, actual %3 s")
1354 .arg(TYPE_NAME[type]).arg(stat.nominal).arg(medInt, 0, 'f', 1);
1355 }
1356 }
1357 }
1358 auto addSats = [&lines](const QString& text, const QMap<QString, unsigned>& sats) {
1359 if (!sats.isEmpty()) {
1360 lines << text + ":" + satList(sats);
1361 }
1362 };
1363 addSats("orbit corrections missing (healthy broadcast ephemeris available)", missOrb);
1364 addSats("clock corrections missing (healthy broadcast ephemeris available)", missClk);
1365 addSats("code biases missing (healthy broadcast ephemeris available)", missCB);
1366 addSats("phase biases missing (healthy broadcast ephemeris available)", missPB);
1367 addSats("corrections referring to an IOD of no received broadcast ephemeris", iodUnknown);
1368 addSats("corrections for unhealthy broadcast ephemeris", unhealthy);
1369 addSats("corrections for satellites without broadcast ephemeris", noEph);
1370 QString staSys = QString("%1 %2").arg(staID).arg(sys);
1371 if (_codeSignals.contains(staSys)) {
1372 QStringList sigs = _codeSignals[staSys].values();
1373 sigs.sort();
1374 lines << "code bias signals: " + sigs.join(" ");
1375 addSats("code biases of the reference signals missing", missRef);
1376 if (biasIF.size() > 1) {
1377 double mean = 0.0;
1378 for (double bb : biasIF) mean += bb / biasIF.size();
1379 double var = 0.0;
1380 for (double bb : biasIF) var += (bb - mean) * (bb - mean) / biasIF.size();
[11119]1381 // the median shows whether the clocks refer to other signals (all
1382 // satellites) or single satellites have inconsistent biases
1383 QVector<double> absIF;
1384 for (double bb : biasIF) absIF << fabs(bb);
[11070]1385 lines << QString("ionosphere-free combination B_IF of the reference signal biases: "
[11119]1386 "std. dev. %1 m, median |B_IF| %2 m, max |B_IF| %3 m (%4) (%5 satellites)")
1387 .arg(sqrt(var), 0, 'f', 3).arg(median(absIF), 0, 'f', 3).arg(maxBiasIF, 0, 'f', 3)
1388 .arg(biasIFPrn).arg(biasIF.size());
[11070]1389 }
1390 }
[11087]1391 compareCodeBiases(staID, sys, lines);
[11073]1392 if (_phaseSignals.contains(staSys)) {
1393 QStringList sigs = _phaseSignals[staSys].values();
1394 sigs.sort();
1395 lines << "phase bias signals: " + sigs.join(" ");
1396 }
[11085]1397 if (numPB) {
1398 if (numYaw == 0) {
1399 lines << "yaw angle: not provided";
1400 }
1401 else {
1402 lines << QString("yaw angle: provided for %1 of %2 satellites (%3 % of the phase bias records)")
1403 .arg(yawSats).arg(yawSats + noYaw.size()).arg(100.0 * numYaw / numPB, 0, 'f', 1);
1404 addSats("phase biases without yaw angle", noYaw);
1405 addSats("phase biases partly without yaw angle (records)", yawPartly);
1406 }
1407 }
[11070]1408 addSats(QString("clock jumps > %1 m").arg(MAX_CLK_JUMP), jumps);
1409 if (!maxRadialPrn.isEmpty()) {
1410 lines << QString("largest orbit corrections: radial %1 m (%2), along/cross %3 m (%4)")
1411 .arg(maxRadial, 0, 'f', 2).arg(maxRadialPrn).arg(maxAlongCross, 0, 'f', 2).arg(maxAlongCrossPrn);
1412 }
[11075]1413 // stream comparison
1414 if (_cmpStat.contains(staID)) {
1415 unsigned nSat = 0, nClk = 0, nOrb = 0, nSis = 0;
1416 double sClk = 0.0, sR = 0.0, sA = 0.0, sC = 0.0, sSis = 0.0;
1417 double vClk = 0.0, vSis = 0.0; // sums of squares about the satellite means
1418 QMap<QString, double> rmsSis, rmsClk, rmsOrb;
1419 QMap<QString, unsigned> notReferred;
1420 QMapIterator<QString, t_cmpStat> itC(_cmpStat[staID]);
1421 while (itC.hasNext()) {
1422 itC.next();
1423 if (itC.key()[0].toLatin1() != sys) continue;
1424 const t_cmpStat& st = itC.value();
1425 if (st.numClk == 0 && st.numOrb == 0) {
1426 if (st.clkNotReferred) notReferred[itC.key()] = 0;
1427 continue;
1428 }
1429 ++nSat;
1430 if (st.clkNotReferred) notReferred[itC.key()] = 0;
1431 nClk += st.numClk; sClk += st.sClk;
1432 nOrb += st.numOrb; sR += st.sR; sA += st.sA; sC += st.sC;
1433 nSis += st.numSis; sSis += st.sSis;
1434 if (st.numClk) vClk += st.sClk - st.mClk * st.mClk / st.numClk;
1435 if (st.numSis) vSis += st.sSis - st.mSis * st.mSis / st.numSis;
1436 if (st.numSis) rmsSis[itC.key()] = sqrt(st.sSis / st.numSis);
1437 else if (st.numClk) rmsClk[itC.key()] = sqrt(st.sClk / st.numClk);
1438 if (st.numOrb) rmsOrb[itC.key()] = sqrt((st.sR + st.sA + st.sC) / st.numOrb);
1439 }
1440 if (nSat) {
[11081]1441 QString refText = (_reference == "CONSENSUS") ? QString("consensus of the other ACs")
[11075]1442 : QString("stream %1").arg(_reference);
1443 QStringList parts;
1444 if (nClk) parts << QString("clock %1 (STD %2)").arg(sqrt(sClk / nClk), 0, 'f', 3)
1445 .arg(sqrt(max(0.0, vClk) / nClk), 0, 'f', 3);
1446 if (nOrb) parts << QString("radial %1, along %2, cross %3")
1447 .arg(sqrt(sR / nOrb), 0, 'f', 3).arg(sqrt(sA / nOrb), 0, 'f', 3)
1448 .arg(sqrt(sC / nOrb), 0, 'f', 3);
1449 if (nSis) parts << QString("SIS (radial - clock) %1 (STD %2)").arg(sqrt(sSis / nSis), 0, 'f', 3)
1450 .arg(sqrt(max(0.0, vSis) / nSis), 0, 'f', 3);
1451 lines << QString("comparison with %1 (%2 satellites), RMS (STD about the satellite means) [m]: %3")
1452 .arg(refText).arg(nSat).arg(parts.join(", "));
1453 if (!nOrb) {
1454 lines << "orbits not compared: no other stream with the same reference point (APC/CoM)";
1455 }
1456 addSats("clocks not compared (code biases of the reference signals missing)", notReferred);
1457 auto deviating = [&lines](const QString& what, const QMap<QString, double>& rms) {
1458 if (rms.size() < 2) return;
1459 double med = median(rms.values().toVector());
1460 QStringList dev;
1461 QMapIterator<QString, double> itR(rms);
1462 while (itR.hasNext()) {
1463 itR.next();
1464 if (itR.value() > max(DEV_MIN, DEV_FACTOR * med)) {
1465 dev << QString("%1(%2)").arg(itR.key()).arg(itR.value(), 0, 'f', 2);
1466 }
1467 }
1468 if (!dev.isEmpty()) {
1469 lines << QString("satellites deviating from the reference, RMS %1 [m]: %2").arg(what).arg(dev.join(" "));
1470 }
1471 };
1472 deviating("SIS", rmsSis);
1473 deviating("clock", rmsClk);
1474 deviating("orbit (3D)", rmsOrb);
1475 }
1476 }
[11070]1477 for (const QString& line : lines) {
[11075]1478 out << " " << sys << " " << line.toStdString() << endl;
[11070]1479 }
1480 }
[11075]1481 out.flush();
[11070]1482 }
1483
[11075]1484 emit newMessage(QString("SSR QC: report %1 %2 of %3 stream(s) written to %4")
[11070]1485 .arg(QString::fromStdString(_intervalStart.datestr()))
1486 .arg(QString::fromStdString(_intervalStart.timestr(0)))
[11075]1487 .arg(streams.size()).arg(_dirName).toLatin1(), false);
[11070]1488
[11075]1489 _cmpStat.clear();
1490 _cmpEpochs.clear();
[11117]1491 for (QMap<QString, t_vtecStat>::iterator it = _vtecStat.begin(); it != _vtecStat.end(); ++it) {
1492 t_vtecStat fresh;
1493 fresh.model = it.value().model;
1494 it.value() = fresh;
1495 }
[11075]1496
[11070]1497 // reset, keeping the state needed across intervals
1498 for (QMap<QString, t_epoStat>::iterator it = _epoStat.begin(); it != _epoStat.end(); ++it) {
1499 t_epoStat& stat = it.value();
1500 bncTime last = stat.last;
1501 int nominal = stat.nominal;
1502 stat = t_epoStat();
1503 stat.last = last;
1504 stat.nominal = nominal;
1505 }
1506 for (QMap<QString, t_satStat>::iterator it = _satStat.begin(); it != _satStat.end(); ++it) {
1507 t_satStat& stat = it.value();
1508 t_satStat fresh;
1509 fresh.lastClkTime = stat.lastClkTime;
1510 fresh.lastIod = stat.lastIod;
1511 fresh.lastClk = stat.lastClk;
1512 fresh.missingRefSig = stat.missingRefSig;
1513 fresh.refBiasOK = stat.refBiasOK;
1514 fresh.biasIF = stat.biasIF;
1515 stat = fresh;
1516 }
1517}
Note: See TracBrowser for help on using the repository browser.