Index: /trunk/BNC/src/ssrQc.cpp
===================================================================
--- /trunk/BNC/src/ssrQc.cpp	(revision 11070)
+++ /trunk/BNC/src/ssrQc.cpp	(revision 11070)
@@ -0,0 +1,501 @@
+// 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 <algorithm>
+#include <cmath>
+#include <iomanip>
+
+#include "ssrQc.h"
+#include "bnccore.h"
+#include "bncutils.h"
+#include "bncconst.h"
+
+using namespace std;
+
+namespace {
+  const double MAX_CLK_JUMP   = 0.5;  // [m] clock change beyond its rate between consecutive epochs
+  const char*  TYPE_NAME[]    = {"ORBIT", "CLOCK", "CODE_BIAS", "PHASE_BIAS"};
+
+  double median(QVector<double> 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<QString, unsigned>& sats, unsigned maxNum = 20) {
+    QString str;
+    unsigned num = 0;
+    QMapIterator<QString, unsigned> 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& fileName, double interval) : _ephUser(true) {
+  _fileName = fileName;
+  _interval = interval > 0.0 ? interval : 3600.0;
+  _out      = new ofstream(fileName.toLocal8Bit().data());
+
+  connect(this, SIGNAL(newMessage(QByteArray,bool)), BNC_CORE, SLOT(slotMessage(const QByteArray,bool)));
+  connect(BNC_CORE, SIGNAL(newOrbCorrections(QList<t_orbCorr>)),
+          this, SLOT(slotNewOrbCorrections(QList<t_orbCorr>)), Qt::DirectConnection);
+  connect(BNC_CORE, SIGNAL(newClkCorrections(QList<t_clkCorr>)),
+          this, SLOT(slotNewClkCorrections(QList<t_clkCorr>)), Qt::DirectConnection);
+  connect(BNC_CORE, SIGNAL(newCodeBiases(QList<t_satCodeBias>)),
+          this, SLOT(slotNewCodeBiases(QList<t_satCodeBias>)), Qt::DirectConnection);
+  connect(BNC_CORE, SIGNAL(newPhaseBiases(QList<t_satPhaseBias>)),
+          this, SLOT(slotNewPhaseBiases(QList<t_satPhaseBias>)), Qt::DirectConnection);
+
+  *_out << "SSR QC of decoded correction streams, 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 biases of the reference signals, clock jumps > " << MAX_CLK_JUMP
+        << " m, largest orbit corrections" << endl;
+}
+
+// Destructor
+////////////////////////////////////////////////////////////////////////////
+t_ssrQc::~t_ssrQc() {
+  QMutexLocker locker(&_mutex);
+  writeReport(true);
+  delete _out;
+}
+
+// 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 (IGS conventions, as in the combination)
+////////////////////////////////////////////////////////////////////////////
+bool t_ssrQc::refSignals(char sys, QString& sig1, QString& sig2,
+                         t_frequency::type& frq1, t_frequency::type& frq2) {
+  switch (sys) {
+    case 'G': sig1 = "1W"; sig2 = "2W"; frq1 = t_frequency::G1; frq2 = t_frequency::G2; return true;
+    case 'R': sig1 = "1P"; sig2 = "2P"; frq1 = t_frequency::R1; frq2 = t_frequency::R2; return true;
+    case 'E': sig1 = "1C"; sig2 = "5Q"; frq1 = t_frequency::E1; frq2 = t_frequency::E5; return true;
+    case 'C': sig1 = "2I"; sig2 = "6I"; frq1 = t_frequency::C2; frq2 = t_frequency::C6; return true;
+    case 'J': sig1 = "1C"; sig2 = "2L"; frq1 = t_frequency::J1; frq2 = t_frequency::J2; return true;
+  }
+  return false;
+}
+
+// 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);
+  }
+  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) {
+  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& prnInt, unsigned iod) {
+  t_eph* ephLast = _ephUser.ephLast(prnInt);
+  t_eph* ephPrev = _ephUser.ephPrev(prnInt);
+  t_eph* eph = 0;
+  if      (ephLast && ephLast->IOD() == iod) eph = ephLast;
+  else if (ephPrev && ephPrev->IOD() == iod) eph = ephPrev;
+  if (!ephLast) {
+    stat.noEph += 1;
+  }
+  else if (!eph) {
+    stat.iodUnknown += 1;
+  }
+  else if (eph->checkState() == t_eph::unhealthy || eph->checkState() == t_eph::bad) {
+    stat.ephUnhealthy += 1;
+  }
+}
+
+// Orbit corrections
+////////////////////////////////////////////////////////////////////////////
+void t_ssrQc::slotNewOrbCorrections(QList<t_orbCorr> orbCorrections) {
+  QMutexLocker locker(&_mutex);
+  for (const t_orbCorr& corr : orbCorrections) {
+    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._prn.toInternalString()), corr._iod);
+    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<t_clkCorr> clkCorrections) {
+  QMutexLocker locker(&_mutex);
+  for (const t_clkCorr& corr : clkCorrections) {
+    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._prn.toInternalString()), corr._iod);
+
+    // 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<t_satCodeBias> codeBiases) {
+  QMutexLocker locker(&_mutex);
+  for (const t_satCodeBias& bias : codeBiases) {
+    char sys = bias._prn.system();
+    QString prn = QString::fromStdString(bias._prn.toString());
+    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<QString, double> values;
+    for (const t_frqCodeBias& frqBias : bias._bias) {
+      QString sig = QString::fromStdString(frqBias._rnxType2ch);
+      _codeSignals[staSys].insert(sig);
+      values[sig] = frqBias._value;
+    }
+    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<t_satPhaseBias> phaseBiases) {
+  QMutexLocker locker(&_mutex);
+  for (const t_satPhaseBias& bias : phaseBiases) {
+    checkInterval(bias._time);
+    newEpoch(bias._staID, bias._prn.system(), PBIAS, bias._time, bias._updateInt,
+             QString::fromStdString(bias._prn.toString()));
+  }
+}
+
+// Write the report of the current interval and reset the statistics
+////////////////////////////////////////////////////////////////////////////
+void t_ssrQc::writeReport(bool final) {
+  if (!_out || _epoStat.isEmpty()) {
+    return;
+  }
+  bncTime endTime = final ? _lastTime : _intervalStart + _interval;
+  *_out << endl << "=== SSR QC " << _intervalStart.datestr() << ' ' << _intervalStart.timestr(0)
+        << " - " << endTime.datestr() << ' ' << endTime.timestr(0)
+        << (final ? " (end of data)" : "") << " ===" << endl;
+
+  // Healthy and current broadcast ephemerides per system
+  QMap<char, QSet<QString> > 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<QString, QSet<char> > streams;
+  QMapIterator<QString, t_epoStat> itEpo(_epoStat);
+  while (itEpo.hasNext()) {
+    itEpo.next();
+    QStringList hlp = itEpo.key().split(' ');
+    streams[hlp[0]].insert(hlp[1][0].toLatin1());
+  }
+
+  QMapIterator<QString, QSet<char> > itSta(streams);
+  while (itSta.hasNext()) {
+    itSta.next();
+    const QString& staID = itSta.key();
+    QList<char> systems = itSta.value().values();
+    std::sort(systems.begin(), systems.end());
+    *_out << endl << "Stream " << staID.toStdString() << 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 <= PBIAS; 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) {
+      QMap<QString, unsigned> missOrb, missClk, missCB, missPB, iodUnknown, unhealthy, noEph,
+                              jumps, missRef;
+      double maxRadial = 0.0, maxAlongCross = 0.0;
+      QString maxRadialPrn, maxAlongCrossPrn;
+      QVector<double> biasIF;
+      const QSet<QString>& 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<QString, unsigned>& 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<QString, t_satStat> 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.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);
+        }
+      }
+      // 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<QString, unsigned>& 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);
+      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();
+          double maxAbs = max(fabs(*std::min_element(biasIF.begin(), biasIF.end())),
+                              fabs(*std::max_element(biasIF.begin(), biasIF.end())));
+          lines << QString("ionosphere-free combination B_IF of the reference signal biases: "
+                           "std. dev. %1 m, max |B_IF| %2 m (%3 satellites)")
+                   .arg(sqrt(var), 0, 'f', 3).arg(maxAbs, 0, 'f', 3).arg(biasIF.size());
+        }
+      }
+      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);
+      }
+      for (const QString& line : lines) {
+        *_out << "  " << sys << " " << line.toStdString() << endl;
+      }
+    }
+  }
+  _out->flush();
+
+  emit newMessage(QString("SSR QC: report %1 %2 written to %3")
+                  .arg(QString::fromStdString(_intervalStart.datestr()))
+                  .arg(QString::fromStdString(_intervalStart.timestr(0)))
+                  .arg(_fileName).toLatin1(), false);
+
+  // reset, keeping the state needed across intervals
+  for (QMap<QString, t_epoStat>::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<QString, t_satStat>::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;
+  }
+}
Index: /trunk/BNC/src/ssrQc.h
===================================================================
--- /trunk/BNC/src/ssrQc.h	(revision 11070)
+++ /trunk/BNC/src/ssrQc.h	(revision 11070)
@@ -0,0 +1,90 @@
+#ifndef SSRQC_H
+#define SSRQC_H
+
+#include <QtCore>
+#include <fstream>
+#include "bncephuser.h"
+#include "bnctime.h"
+#include "satObs.h"
+
+// Quality control of the decoded SSR streams: epochs and update intervals,
+// latency, completeness with respect to the broadcast ephemerides, IOD
+// matching, code bias signals, clock jumps and orbit correction size. A
+// report per stream and system is written to the QC logfile at the end of
+// each interval and at the end of the program.
+////////////////////////////////////////////////////////////////////////////
+class t_ssrQc : public QObject {
+ Q_OBJECT
+
+ public:
+  t_ssrQc(const QString& fileName, double interval);
+  ~t_ssrQc();
+
+ public slots:
+  void slotNewOrbCorrections(QList<t_orbCorr> orbCorrections);
+  void slotNewClkCorrections(QList<t_clkCorr> clkCorrections);
+  void slotNewCodeBiases(QList<t_satCodeBias> codeBiases);
+  void slotNewPhaseBiases(QList<t_satPhaseBias> phaseBiases);
+
+ signals:
+  void newMessage(QByteArray msg, bool showOnScreen);
+
+ private:
+  enum e_type {ORB = 0, CLK = 1, CBIAS = 2, PBIAS = 3};
+
+  class t_epoStat {          // per stream, system and message type
+   public:
+    t_epoStat() : nominal(0), numEpo(0), numOutOfOrder(0) {}
+    int             nominal;       // nominal update interval [s]
+    unsigned        numEpo;
+    unsigned        numOutOfOrder;
+    bncTime         last;
+    QVector<double> intervals;
+    QVector<double> latencies;
+    QSet<QString>   sats;
+  };
+
+  class t_satStat {          // per stream and satellite
+   public:
+    t_satStat() : numOrb(0), numClk(0), iodUnknown(0), ephUnhealthy(0), noEph(0),
+                  clkJumps(0), lastIod(0), lastClk(0.0), maxJump(0.0),
+                  maxRadial(0.0), maxAlongCross(0.0) {}
+    unsigned      numOrb;
+    unsigned      numClk;
+    unsigned      iodUnknown;    // IOD of no received broadcast ephemeris
+    unsigned      ephUnhealthy;  // broadcast ephemeris unhealthy/bad
+    unsigned      noEph;         // no broadcast ephemeris at all
+    unsigned      clkJumps;
+    bncTime       lastClkTime;
+    unsigned      lastIod;
+    double        lastClk;       // [m]
+    double        maxJump;       // [m]
+    double        maxRadial;     // [m]
+    double        maxAlongCross; // [m]
+    QSet<QString> missingRefSig; // reference signals without code bias
+    bool          refBiasOK = false;
+    double        biasIF = 0.0;  // ionosphere-free combination of the reference biases [m]
+  };
+
+  void   newEpoch(const std::string& staID, char sys, e_type type, const bncTime& time,
+                  unsigned updateInt, const QString& prn);
+  void   checkEph(t_satStat& stat, const QString& prnInt, unsigned iod);
+  void   checkInterval(const bncTime& time);
+  void   writeReport(bool final);
+  static QString key(const std::string& staID, char sys, e_type type);
+  static bool    refSignals(char sys, QString& sig1, QString& sig2,
+                            t_frequency::type& frq1, t_frequency::type& frq2);
+
+  QMutex                    _mutex;
+  QString                   _fileName;
+  std::ofstream*            _out;
+  double                    _interval;
+  bncTime                   _intervalStart;
+  bncTime                   _lastTime;
+  bncEphUser                _ephUser;
+  QMap<QString, t_epoStat>  _epoStat;    // key "STA SYS TYPE"
+  QMap<QString, t_satStat>  _satStat;    // key "STA PRN"
+  QMap<QString, QSet<QString> > _codeSignals; // key "STA SYS": signals with code biases
+};
+
+#endif
