// 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://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:      RTCM3coDecoder
 *
 * Purpose:    RTCM3 Clock Orbit Decoder
 *
 * Author:     L. Mervart
 *
 * Created:    05-May-2008
 *
 * Changes:
 *
 * -----------------------------------------------------------------------*/

#include <stdio.h>
#include <math.h>
#include <climits>

#include "RTCM3coDecoder.h"
#include "bncutils.h"
#include "bncrinex.h"
#include "bnccore.h"
#include "bncsettings.h"
#include "bnctime.h"

using namespace std;

// Constructor
////////////////////////////////////////////////////////////////////////////
RTCM3coDecoder::RTCM3coDecoder(const QString& staID) {

  _staID = staID;

  // File Output
  // -----------
  bncSettings settings;
  QString path = settings.value("corrPath").toString();
  if (!path.isEmpty()) {
    expandEnvVar(path);
    if ( path.length() > 0 && path[path.length()-1] != QDir::separator() ) {
      path += QDir::separator();
    }
    _fileNameSkl = path + staID;
  }
  _out = 0;

  Qt::ConnectionType conType = BNC_CORE->decoderConnectionType();
  connect(this, SIGNAL(newOrbCorrections(QList<t_orbCorr>)),
          BNC_CORE, SLOT(slotNewOrbCorrections(QList<t_orbCorr>)), conType);

  connect(this, SIGNAL(newClkCorrections(QList<t_clkCorr>)),
          BNC_CORE, SLOT(slotNewClkCorrections(QList<t_clkCorr>)), conType);

  connect(this, SIGNAL(newCodeBiases(QList<t_satCodeBias>)),
          BNC_CORE, SLOT(slotNewCodeBiases(QList<t_satCodeBias>)), conType);

  connect(this, SIGNAL(newPhaseBiases(QList<t_satPhaseBias>)),
          BNC_CORE, SLOT(slotNewPhaseBiases(QList<t_satPhaseBias>)), conType);

  connect(this, SIGNAL(newTec(t_vTec)),
          BNC_CORE, SLOT(slotNewTec(t_vTec)), conType);

  connect(this, SIGNAL(newMetaData(t_metaData)),
          BNC_CORE, SLOT(slotNewMetaData(t_metaData)), conType);

  connect(this, SIGNAL(newSatAntennas(QList<t_satAntenna>)),
          BNC_CORE, SLOT(slotNewSatAntennas(QList<t_satAntenna>)), conType);

  connect(this, SIGNAL(providerIDChanged(QString)),
          BNC_CORE, SIGNAL(providerIDChanged(QString)), conType);

  connect(this, SIGNAL(newMessage(QByteArray,bool)),
          BNC_CORE, SLOT(slotMessage(const QByteArray,bool)));

  reset();

  _providerID[0] = -1;
  _providerID[1] = -1;
  _providerID[2] = -1;

  for (unsigned s = 0; s < CLOCKORBIT_SATNUM; s++) {
    _antennaReceived[s]    = false;
    _antennaIODKnown[s]    = false;
    _antennaIODProvider[s] = 0;
    _antennaIODCur[s]      = 0;
    _antennaIODPrev[s]     = 0;
  }
  for (unsigned i = 0; i < CLOCKORBIT_COUNTSAT; i++) {
    _antennaSentProviderID[i] = 0;
    _antennaSentIOD[i]        = 0; // meaningless while _antennaSentTime[i] is invalid ("never sent")
  }

  _stateSnapshot = new t_stateSnapshot;
  _ssrCorr = 0;

}

// Destructor
////////////////////////////////////////////////////////////////////////////
RTCM3coDecoder::~RTCM3coDecoder() {
  delete _out;
  delete _ssrCorr;
  delete _stateSnapshot;
  _IODs.clear();
  _orbCorrections.clear();
  _clkCorrections.clear();
  _lastClkCorrections.clear();
  _codeBiases.clear();
  _phaseBiases.clear();
  _vTecMap.clear();
  _metaDataMap.clear();
  _satAntennas.clear();
}

//
////////////////////////////////////////////////////////////////////////////
void RTCM3coDecoder::reset(bool resetPhaseBias) {
  memset(&_clkOrb,    0, sizeof(_clkOrb));
  memset(&_codeBias,  0, sizeof(_codeBias));
  if (resetPhaseBias) {
    memset(&_phaseBias, 0, sizeof(_phaseBias));
    for (unsigned s = 0; s < CLOCKORBIT_SATNUM; s++) {
      _phaseBiasSentEpoch[s] = UINT_MAX; // "never sent"
    }
  }
  memset(&_vTEC,      0, sizeof(_vTEC));
}

// Reopen Output File
////////////////////////////////////////////////////////////////////////
void RTCM3coDecoder::reopen() {

  if (!_fileNameSkl.isEmpty()) {

    bncSettings settings;

    QDateTime datTim = currentDateAndTimeGPS();

    QString hlpStr = bncRinex::nextEpochStr(datTim,
                                      settings.value("corrIntr").toString(), 3);

    QString cntStr = (_fileNameSkl.contains("ION")) ? "_ION.ssr" : "_MC.ssr";

    QString fileNameHlp = _fileNameSkl +
                      "_S_" +     // stream
                      QString("%1").arg(datTim.date().year()) +
                      QString("%1").arg(datTim.date().dayOfYear(), 3, 10, QChar('0')) +
                      hlpStr +  // HM_period
					  cntStr;   // mixed ION or CLK

    if (_fileName == fileNameHlp) {
      return;
    }
    else {
      _fileName = fileNameHlp;
    }

    delete _out;
    if ( Qt::CheckState(settings.value("rnxAppend").toInt()) == Qt::Checked) {
      _out = new ofstream( _fileName.toLatin1().data(), ios_base::out | ios_base::app );
    }
    else {
      _out = new ofstream( _fileName.toLatin1().data() );
    }
  }
}

//
////////////////////////////////////////////////////////////////////////////
t_irc RTCM3coDecoder::Decode(char* buffer, int bufLen, vector<string>& errmsg) {

  errmsg.clear();

  _buffer.append(QByteArray(buffer,bufLen));

  t_irc retCode = failure;

  while(_buffer.size()) {

    // save state
    memcpy(&_stateSnapshot->clkOrb,    &_clkOrb,    sizeof(_clkOrb));
    memcpy(&_stateSnapshot->codeBias,  &_codeBias,  sizeof(_codeBias));
    memcpy(&_stateSnapshot->phaseBias, &_phaseBias, sizeof(_phaseBias));
    memcpy(&_stateSnapshot->vTEC,      &_vTEC,      sizeof(_vTEC));
    memcpy(&_stateSnapshot->metaData,  &_metaData,  sizeof(_metaData));
    memcpy(&_stateSnapshot->antenna,   &_antenna,   sizeof(_antenna));

    // _antenna persists across messages, so its content alone can't tell
    // whether a Satellite Antenna message arrived in this frame (an unchanged
    // retransmission looks identical). Clear messageType[] as a marker: the
    // COBOFS_SATANT decoder sets it (always non-zero) for each system it
    // decodes. Restored with the snapshot if the frame turns out incomplete.
    for (unsigned s = 0; s < CLOCKORBIT_SATNUM; s++) {
      _antenna.messageType[s] = 0;
    }

    int bytesused = 0;

    GCOB_RETURN irc = _ssrCorr->GetSSR(&_clkOrb, &_codeBias, &_vTEC, &_phaseBias, &_metaData, &_antenna,
                                       _buffer.data(), _buffer.size(), &bytesused);

    if      (irc <= -30) { // not enough data - restore state and exit loop
      memcpy(&_clkOrb,     &_stateSnapshot->clkOrb,    sizeof(_clkOrb));
      memcpy(&_codeBias,   &_stateSnapshot->codeBias,  sizeof(_codeBias));
      memcpy(&_phaseBias,  &_stateSnapshot->phaseBias, sizeof(_phaseBias));
      memcpy(&_vTEC,       &_stateSnapshot->vTEC,      sizeof(_vTEC));
      memcpy(&_metaData,   &_stateSnapshot->metaData,  sizeof(_metaData));
      memcpy(&_antenna,    &_stateSnapshot->antenna,   sizeof(_antenna));
      break;
    }

    else if (irc < 0) {    // error - skip this message (or 1 byte if the
                           // frame itself couldn't be recognized) and retry
      if (irc != GCOBR_UNKNOWNTYPE) {
        // A recognized-but-unimplemented message (e.g. Tropospheric or
        // Regional Ionospheric Correction) leaves _clkOrb/_codeBias/
        // _phaseBias/_vTEC untouched, and bytesused is already reliably set
        // from its CRC-validated frame length, so there is nothing to
        // discard. Every other error code may have left inconsistent
        // partial state (e.g. mid-satellite-loop) or an unreliable frame
        // boundary, so reset defensively there. Without this, streams with
        // frequent unimplemented messages (e.g. dense grid-based Regional
        // Ionospheric traffic) would wipe unrelated, still-valid orbit/
        // clock/bias/phase-bias state every time one is skipped.
        reset();
      }
      _buffer = _buffer.mid(bytesused ? bytesused : 1);
    }

    else {                 // OK or MESSAGEFOLLOWS
      _buffer = _buffer.mid(bytesused);

      if (irc == GCOBR_OK || irc == GCOBR_MESSAGEFOLLOWS ) {
        // Latch the reception until sendResults() consumes it - it may not
        // run for this frame if _lastTime is still invalid.
        for (unsigned s = 0; s < CLOCKORBIT_SATNUM; s++) {
          if (_antenna.messageType[s] != 0) {
            _antennaReceived[s] = true;
            checkAntennaIOD(s);
          }
        }
        setEpochTime(); // sets _lastTime

        if (_lastTime.valid()) {
          reopen();
          checkProviderID();
          sendResults();
          retCode = success;
        }
        else {
          emit newMessage("RTCM3coDecoder: _lastTime invalid: " + _staID.toLatin1(), true);
          retCode = failure;
        }
        // Keep _phaseBias intact: an Extended Phase Bias message (COBOFS_PBEXT)
        // arriving later needs to correlate against the satellite/signal list
        // just published by this Phase Bias message. clock_orbit_rtcm_new.cpp's
        // COBOFS_PBIAS handling resets pb->NumberOfSat[s] itself before decoding
        // a fresh message, so this doesn't cause stale data to accumulate.
        reset(false);
      }
    }
  }

  return retCode;
}

//
////////////////////////////////////////////////////////////////////////////
void RTCM3coDecoder::sendResults() {

  // Orbit and clock corrections of all satellites
  // ---------------------------------------------
  for (unsigned ii = 0; ii <  CLOCKORBIT_NUMGPS
                            + CLOCKORBIT_NUMGLONASS
                            + CLOCKORBIT_NUMGALILEO
                            + CLOCKORBIT_NUMQZSS
                            + CLOCKORBIT_NUMSBAS
                            + _clkOrb.NumberOfSat[CLOCKORBIT_SATBDS];
    ii++) {
    if (corrIsOutOfRange(_clkOrb.Sat[ii])) {
      continue;
    }
    char sys = ' ';
    int  num  = _clkOrb.Sat[ii].ID;
    int  flag = 0;  // to force NAV type usage according SSR standard
    if      (ii < _clkOrb.NumberOfSat[CLOCKORBIT_SATGPS]) {
      sys = 'G';
      flag = t_eph::LNAV;
    }
    else if (ii >= CLOCKORBIT_OFFSETGLONASS &&
        ii < CLOCKORBIT_OFFSETGLONASS + _clkOrb.NumberOfSat[CLOCKORBIT_SATGLONASS]) {
      sys = 'R';
      flag = t_eph::FDMA_M;
    }
    else if (ii >= CLOCKORBIT_OFFSETGALILEO &&
        ii < CLOCKORBIT_OFFSETGALILEO + _clkOrb.NumberOfSat[CLOCKORBIT_SATGALILEO]) {
      sys = 'E';
      flag = t_eph::INAV;
    }
    else if (ii >= CLOCKORBIT_OFFSETQZSS &&
        ii < CLOCKORBIT_OFFSETQZSS + _clkOrb.NumberOfSat[CLOCKORBIT_SATQZSS]) {
      sys = 'J';
      flag = t_eph::LNAV;
    }
    else if (ii >= CLOCKORBIT_OFFSETSBAS &&
        ii < CLOCKORBIT_OFFSETSBAS + _clkOrb.NumberOfSat[CLOCKORBIT_SATSBAS]) {
      sys = 'S';
      flag = t_eph::SBASL1;
    }
    else if (ii >= CLOCKORBIT_OFFSETBDS &&
        ii < CLOCKORBIT_OFFSETBDS + _clkOrb.NumberOfSat[CLOCKORBIT_SATBDS]) {
      sys = 'C';
      if (num < 6) {// GEO
        flag = t_eph::D2;
      }
      else if (num > 58 && num < 63) { // GEO
        flag = t_eph::D2;
      }
      else {
        flag = t_eph::D1;
      }
    }
    else {
      continue;
    }

    // Orbit correction
    // ----------------
    if ( _clkOrb.messageType == _ssrCorr->COTYPE_GPSCOMBINED     ||
         _clkOrb.messageType == _ssrCorr->COTYPE_GLONASSCOMBINED ||
         _clkOrb.messageType == _ssrCorr->COTYPE_GALILEOCOMBINED ||
         _clkOrb.messageType == _ssrCorr->COTYPE_QZSSCOMBINED ||
         _clkOrb.messageType == _ssrCorr->COTYPE_SBASCOMBINED ||
         _clkOrb.messageType == _ssrCorr->COTYPE_BDSCOMBINED ||
         _clkOrb.messageType == _ssrCorr->COTYPE_GPSORBIT ||
         _clkOrb.messageType == _ssrCorr->COTYPE_GLONASSORBIT ||
         _clkOrb.messageType == _ssrCorr->COTYPE_GALILEOORBIT ||
         _clkOrb.messageType == _ssrCorr->COTYPE_QZSSORBIT ||
         _clkOrb.messageType == _ssrCorr->COTYPE_SBASORBIT ||
         _clkOrb.messageType == _ssrCorr->COTYPE_BDSORBIT ) {

      t_orbCorr orbCorr;
      orbCorr._prn.set(sys, num, flag);
      checkPrnRange(orbCorr._prn);
      orbCorr._staID     = _staID.toStdString();
      orbCorr._iod       = _clkOrb.Sat[ii].IOD;
      orbCorr._time      = _lastTime;
      orbCorr._updateInt = _clkOrb.UpdateInterval;
      orbCorr._system    = sys;
      orbCorr._xr[0]     = _clkOrb.Sat[ii].Orbit.DeltaRadial;
      orbCorr._xr[1]     = _clkOrb.Sat[ii].Orbit.DeltaAlongTrack;
      orbCorr._xr[2]     = _clkOrb.Sat[ii].Orbit.DeltaCrossTrack;
      orbCorr._dotXr[0]  = _clkOrb.Sat[ii].Orbit.DotDeltaRadial;
      orbCorr._dotXr[1]  = _clkOrb.Sat[ii].Orbit.DotDeltaAlongTrack;
      orbCorr._dotXr[2]  = _clkOrb.Sat[ii].Orbit.DotDeltaCrossTrack;
      orbCorr._ssrFormat = (_type == RTCMssr) ? ssrRtcmOld :
                            (_type == RTCMnewssr) ? ssrRtcmNew : ssrUnknown;

      _orbCorrections[_lastTime].append(orbCorr);

      _IODs[orbCorr._prn] = _clkOrb.Sat[ii].IOD;
    }

    // Clock Corrections
    // -----------------
    if ( _clkOrb.messageType == _ssrCorr->COTYPE_GPSCOMBINED     ||
         _clkOrb.messageType == _ssrCorr->COTYPE_GLONASSCOMBINED ||
         _clkOrb.messageType == _ssrCorr->COTYPE_GALILEOCOMBINED ||
         _clkOrb.messageType == _ssrCorr->COTYPE_QZSSCOMBINED ||
         _clkOrb.messageType == _ssrCorr->COTYPE_SBASCOMBINED ||
         _clkOrb.messageType == _ssrCorr->COTYPE_BDSCOMBINED ||
         _clkOrb.messageType == _ssrCorr->COTYPE_GPSCLOCK ||
         _clkOrb.messageType == _ssrCorr->COTYPE_GLONASSCLOCK ||
         _clkOrb.messageType == _ssrCorr->COTYPE_GALILEOCLOCK ||
         _clkOrb.messageType == _ssrCorr->COTYPE_QZSSCLOCK ||
         _clkOrb.messageType == _ssrCorr->COTYPE_SBASCLOCK ||
         _clkOrb.messageType == _ssrCorr->COTYPE_BDSCLOCK) {

      t_clkCorr clkCorr;
      clkCorr._prn.set(sys, _clkOrb.Sat[ii].ID, flag);
      checkPrnRange(clkCorr._prn);
      clkCorr._staID      = _staID.toStdString();
      clkCorr._time       = _lastTime;
      clkCorr._updateInt  = _clkOrb.UpdateInterval;
      clkCorr._dClk       = _clkOrb.Sat[ii].Clock.DeltaA0 / t_CST::c;
      clkCorr._dotDClk    = _clkOrb.Sat[ii].Clock.DeltaA1 / t_CST::c;
      clkCorr._dotDotDClk = _clkOrb.Sat[ii].Clock.DeltaA2 / t_CST::c;

      _lastClkCorrections[clkCorr._prn] = clkCorr;

      if (_IODs.contains(clkCorr._prn)) {
        clkCorr._iod = _IODs[clkCorr._prn];
        _clkCorrections[_lastTime].append(clkCorr);
      }
    }

    // High-Resolution Clocks
    // ----------------------
    if ( _clkOrb.messageType == _ssrCorr->COTYPE_GPSHR     ||
         _clkOrb.messageType == _ssrCorr->COTYPE_GLONASSHR ||
         _clkOrb.messageType == _ssrCorr->COTYPE_GALILEOHR ||
         _clkOrb.messageType == _ssrCorr->COTYPE_QZSSHR ||
         _clkOrb.messageType == _ssrCorr->COTYPE_SBASHR ||
         _clkOrb.messageType == _ssrCorr->COTYPE_BDSHR) {
      t_prn prn(sys, _clkOrb.Sat[ii].ID, flag);
      if (_lastClkCorrections.contains(prn)) {
        t_clkCorr clkCorr;
        clkCorr            = _lastClkCorrections[prn];
        clkCorr._time      = _lastTime;
        clkCorr._updateInt = _clkOrb.UpdateInterval;
        clkCorr._dClk     += _clkOrb.Sat[ii].hrclock / t_CST::c;
        if (_IODs.contains(clkCorr._prn)) {
          clkCorr._iod = _IODs[clkCorr._prn];
          _clkCorrections[_lastTime].append(clkCorr);
        }
      }
    }
  }

  // Code Biases
  // -----------
  for (unsigned ii = 0; ii <  CLOCKORBIT_NUMGPS
                            + CLOCKORBIT_NUMGLONASS
                            + CLOCKORBIT_NUMGALILEO
                            + CLOCKORBIT_NUMQZSS
                            + CLOCKORBIT_NUMSBAS
                            + _codeBias.NumberOfSat[CLOCKORBIT_SATBDS];
    ii++) {
    char sys = ' ';
    int  num =  _codeBias.Sat[ii].ID;
    int flag = 0;
    if      (ii < _codeBias.NumberOfSat[CLOCKORBIT_SATGPS]) {
      sys = 'G';
      flag = t_eph::LNAV;
    }
    else if (ii >= CLOCKORBIT_OFFSETGLONASS &&
        ii < CLOCKORBIT_OFFSETGLONASS + _codeBias.NumberOfSat[CLOCKORBIT_SATGLONASS]) {
      sys = 'R';
      flag = t_eph::FDMA_M;
    }
    else if (ii >= CLOCKORBIT_OFFSETGALILEO &&
        ii < CLOCKORBIT_OFFSETGALILEO + _codeBias.NumberOfSat[CLOCKORBIT_SATGALILEO]) {
      sys = 'E';
      flag = t_eph::INAV;
    }
    else if (ii >= CLOCKORBIT_OFFSETQZSS &&
        ii < CLOCKORBIT_OFFSETQZSS + _codeBias.NumberOfSat[CLOCKORBIT_SATQZSS]) {
      sys = 'J';
      flag = t_eph::LNAV;
    }
    else if (ii >= CLOCKORBIT_OFFSETSBAS &&
        ii < CLOCKORBIT_OFFSETSBAS + _codeBias.NumberOfSat[CLOCKORBIT_SATSBAS]) {
      sys = 'S';
      flag = t_eph::SBASL1;
    }
    else if (ii >= CLOCKORBIT_OFFSETBDS &&
        ii < CLOCKORBIT_OFFSETBDS + _codeBias.NumberOfSat[CLOCKORBIT_SATBDS]) {
      sys = 'C';
      if (num < 6) {// GEO
        flag = t_eph::D2;
      }
      else if (num > 58 && num < 63) { // GEO
        flag = t_eph::D2;
      }
      else {
        flag = t_eph::D1;
      }
    }
    else {
      continue;
    }
    t_satCodeBias satCodeBias;
    satCodeBias._prn.set(sys, num, flag);
    checkPrnRange(satCodeBias._prn);
    satCodeBias._staID     = _staID.toStdString();
    satCodeBias._time      = _lastTime;
    satCodeBias._updateInt = _codeBias.UpdateInterval;
    satCodeBias._ssrIOD        = _codeBias.SSRIOD;
    satCodeBias._ssrProviderID = _codeBias.SSRProviderID;
    for (unsigned jj = 0; jj < _codeBias.Sat[ii].NumberOfCodeBiases; jj++) {
      const SsrCorr::CodeBias::BiasSat::CodeBiasEntry& biasEntry = _codeBias.Sat[ii].Biases[jj];
      t_frqCodeBias frqCodeBias;
      frqCodeBias._rnxType2ch.assign(_ssrCorr->codeTypeToRnxType(sys, biasEntry.Type));
      frqCodeBias._value      = biasEntry.Bias;
      if (!frqCodeBias._rnxType2ch.empty()) {
        satCodeBias._bias.push_back(frqCodeBias);
      }
    }
    _codeBiases[_lastTime].append(satCodeBias);
  }

  // Phase Biases
  // -----------
  // _phaseBias is no longer wiped between messages (kept alive so a later
  // Extended Phase Bias message can correlate against it - see
  // COBOFS_PBEXT), so without this guard the block below would re-append
  // the same system's satellites into the output on every other message
  // decoded in between two actual phase-bias messages. Only emit a
  // system's satellites once per epoch, tracked per system by comparing
  // against the epoch we last actually sent for it.
  bool phaseBiasFresh[CLOCKORBIT_SATNUM];
  for (unsigned s = 0; s < CLOCKORBIT_SATNUM; s++) {
    phaseBiasFresh[s] = _phaseBias.NumberOfSat[s] > 0 &&
                         _phaseBias.EpochTime[s] != _phaseBiasSentEpoch[s];
    if (phaseBiasFresh[s]) {
      _phaseBiasSentEpoch[s] = _phaseBias.EpochTime[s];
    }
  }
  for (unsigned ii = 0; ii <  CLOCKORBIT_NUMGPS
                            + CLOCKORBIT_NUMGLONASS
                            + CLOCKORBIT_NUMGALILEO
                            + CLOCKORBIT_NUMQZSS
                            + CLOCKORBIT_NUMSBAS
                            + _phaseBias.NumberOfSat[CLOCKORBIT_SATBDS];
    ii++) {
    char sys = ' ';
    int num = _phaseBias.Sat[ii].ID;
    int flag = 0;
    if      (ii < _phaseBias.NumberOfSat[CLOCKORBIT_SATGPS]) {
      if (!phaseBiasFresh[CLOCKORBIT_SATGPS]) continue;
      sys = 'G';
      flag = t_eph::LNAV;
    }
    else if (ii >= CLOCKORBIT_OFFSETGLONASS &&
        ii < CLOCKORBIT_OFFSETGLONASS + _phaseBias.NumberOfSat[CLOCKORBIT_SATGLONASS]) {
      if (!phaseBiasFresh[CLOCKORBIT_SATGLONASS]) continue;
      sys = 'R';
      flag = t_eph::FDMA_M;
    }
    else if (ii >= CLOCKORBIT_OFFSETGALILEO &&
        ii < CLOCKORBIT_OFFSETGALILEO + _phaseBias.NumberOfSat[CLOCKORBIT_SATGALILEO]) {
      if (!phaseBiasFresh[CLOCKORBIT_SATGALILEO]) continue;
      sys = 'E';
      flag = t_eph::INAV;
    }
    else if (ii >= CLOCKORBIT_OFFSETQZSS &&
        ii < CLOCKORBIT_OFFSETQZSS + _phaseBias.NumberOfSat[CLOCKORBIT_SATQZSS]) {
      if (!phaseBiasFresh[CLOCKORBIT_SATQZSS]) continue;
      sys = 'J';
      flag = t_eph::LNAV;
    }
    else if (ii >= CLOCKORBIT_OFFSETSBAS &&
        ii < CLOCKORBIT_OFFSETSBAS + _phaseBias.NumberOfSat[CLOCKORBIT_SATSBAS]) {
      if (!phaseBiasFresh[CLOCKORBIT_SATSBAS]) continue;
      sys = 'S';
      flag = t_eph::SBASL1;
    }
    else if (ii >= CLOCKORBIT_OFFSETBDS &&
        ii < CLOCKORBIT_OFFSETBDS + _phaseBias.NumberOfSat[CLOCKORBIT_SATBDS]) {
      if (!phaseBiasFresh[CLOCKORBIT_SATBDS]) continue;
      sys = 'C';
      if (num < 6) {// GEO
        flag = t_eph::D2;
      }
      else if (num > 58 && num < 63) { // GEO
        flag = t_eph::D2;
      }
      else {
        flag = t_eph::D1;
      }
    }
    else {
      continue;
    }
    t_satPhaseBias satPhaseBias;
    satPhaseBias._prn.set(sys, num, flag);
    checkPrnRange(satPhaseBias._prn);
    satPhaseBias._staID      = _staID.toStdString();
    satPhaseBias._time       = _lastTime;
    satPhaseBias._updateInt  = _phaseBias.UpdateInterval;
    satPhaseBias._ssrIOD        = _phaseBias.SSRIOD;
    satPhaseBias._ssrProviderID = _phaseBias.SSRProviderID;
    satPhaseBias._ssrFormat  = (_type == RTCMssr) ? ssrRtcmOld :
                               (_type == RTCMnewssr) ? ssrRtcmNew : ssrUnknown;
    if (satPhaseBias._ssrFormat == e_ssrFormat::ssrRtcmNew) {
      satPhaseBias._satYawInfoInd = _phaseBias.SatelliteYawInformationIndicator;
      satPhaseBias._extPBPhaseInd = _phaseBias.ExtendedPhaseBiasPropertyID;
    }  
    else {
      satPhaseBias._dispBiasConsistInd = _phaseBias.DispersiveBiasConsistencyIndicator;
      satPhaseBias._MelbWuebConsistInd = _phaseBias.MWConsistencyIndicator;
    }
    satPhaseBias._yaw     = _phaseBias.Sat[ii].YawAngle;
    satPhaseBias._yawRate = _phaseBias.Sat[ii].YawRate;
    for (unsigned jj = 0; jj < _phaseBias.Sat[ii].NumberOfPhaseBiases; jj++) {
      const SsrCorr::PhaseBias::PhaseBiasSat::PhaseBiasEntry& biasEntry = _phaseBias.Sat[ii].Biases[jj];
      t_frqPhaseBias frqPhaseBias;
      frqPhaseBias._rnxType2ch.assign(_ssrCorr->codeTypeToRnxType(sys, biasEntry.Type));
      frqPhaseBias._value        = biasEntry.Bias;
      frqPhaseBias._fixIndicator = biasEntry.IntegerIndicator;
      if (satPhaseBias._ssrFormat == e_ssrFormat::ssrRtcmNew) {
        if (satPhaseBias._extPBPhaseInd) {
          frqPhaseBias._fixWideLaneIndicator = biasEntry.WidelaneGroupIndicator;
        }
      }
      else {
        frqPhaseBias._fixWideLaneIndicator = biasEntry.WidelaneGroupIndicator;
      }
      frqPhaseBias._jumpCounter = biasEntry.DiscontinuityCounter;
      if (!frqPhaseBias._rnxType2ch.empty()) {
        satPhaseBias._bias.push_back(frqPhaseBias);
      }
    }
    _phaseBiases[_lastTime].append(satPhaseBias);
  }

  // Ionospheric Model
  // -----------------
  if (_vTEC.NumLayers > 0) {
    _vTecMap[_lastTime]._time  = _lastTime;
    _vTecMap[_lastTime]._updateInt =  _vTEC.UpdateInterval;
    _vTecMap[_lastTime]._staID = _staID.toStdString();
    for (unsigned ii = 0; ii < _vTEC.NumLayers; ii++) {
      const SsrCorr::VTEC::IonoLayers& ionoLayer = _vTEC.Layers[ii];
      t_vTecLayer layer;
      layer._height = ionoLayer.Height;
      layer._C.ReSize(ionoLayer.Degree+1, ionoLayer.Order+1);
      layer._S.ReSize(ionoLayer.Degree+1, ionoLayer.Order+1);
      for (unsigned iDeg = 0; iDeg <= ionoLayer.Degree; iDeg++) {
        for (unsigned iOrd = 0; iOrd <= ionoLayer.Order; iOrd++) {
          layer._C[iDeg][iOrd] = ionoLayer.Cosinus[iDeg][iOrd];
          layer._S[iDeg][iOrd] = ionoLayer.Sinus[iDeg][iOrd];
        }
      }
      _vTecMap[_lastTime]._layers.push_back(layer);
    }
  }

  // Metadata (model correction information)
  // ----------------------------------------
  if (_metaData.NumEntries > 0) {
    // Metadata carries no epoch field of its own (it is keyed here by
    // _lastTime, inherited from whichever other message last set it), so a
    // fresh, complete message must replace rather than accumulate onto
    // whatever a previous message at the same _lastTime already stored.
    _metaDataMap[_lastTime] = t_metaData();
    _metaDataMap[_lastTime]._time       = _lastTime;
    _metaDataMap[_lastTime]._staID      = _staID.toStdString();
    _metaDataMap[_lastTime]._ssrIOD     = _metaData.SSRIOD;
    _metaDataMap[_lastTime]._providerID = _metaData.SSRProviderID;
    _metaDataMap[_lastTime]._solutionID = _metaData.SSRSolutionID;
    for (unsigned ii = 0; ii < _metaData.NumEntries; ii++) {
      const SsrCorr::MetaData::ModelPart& part = _metaData.Entries[ii];
      t_metaDataEntry entry;
      entry._typeIndicator        = part.TypeIndicator;
      entry._applicationIndicator = part.ApplicationIndicator;
      entry._nonDefaultIndicator  = part.nonDefaultIndicator;
      entry._nonDefaultIdentifier = part.nonDefaultIdentifier;
      entry._dataIODIndicator     = part.DataIODIndicator;
      entry._dataIOD              = part.DataIOD;
      _metaDataMap[_lastTime]._entries.push_back(entry);
    }
  }

  // Satellite Antenna corrections
  // ------------------------------
  // _antenna, like _metaData, carries no epoch field of its own and is never
  // wiped by reset(), so each system's data persists across unrelated
  // messages. Only a system for which an Antenna message was actually
  // received (_antennaReceived, latched in Decode()) is considered, so
  // persisted data is never re-stamped with a newer time.
  //
  // The SatelliteAntennaIOD is unique only per SSR Provider ID (DF414) and
  // only within 64 days, and a client that has not received the stream for
  // 64 days must discard cached antenna data (enforced downstream against
  // t_satAntenna::_time, see ssrSatAntennaTrusted() in pppSatObs.cpp). So an
  // unchanged (provider ID, IOD) retransmission is suppressed only if it was
  // last emitted less than ANT_REFRESH_SEC ago. Re-emitting it after that
  // keeps _time close to the last reception - not the first - so continuously
  // received data never ages out. It also means a reception after a long
  // gap is always emitted, even when the provider has legitimately reused
  // the IOD for different content, and each .ssr file contains the antenna
  // data at least once per refresh interval.
  //
  // This is tracked per satellite, not per system: providers may split a
  // system's satellites across several messages with rotating satellite
  // masks (DF394), all carrying the same IOD - a per-system check would
  // emit only whichever subset happened to arrive first.
  {
    const double ANT_REFRESH_SEC = 3600.0;
    struct SysInfo { unsigned sysIdx; char sysChar; unsigned numSat; unsigned offset; };
    const SysInfo sysInfos[] = {
      { CLOCKORBIT_SATGPS,     'G', CLOCKORBIT_NUMGPS,     CLOCKORBIT_OFFSETGPS },
      { CLOCKORBIT_SATGLONASS, 'R', CLOCKORBIT_NUMGLONASS, CLOCKORBIT_OFFSETGLONASS },
      { CLOCKORBIT_SATGALILEO, 'E', CLOCKORBIT_NUMGALILEO, CLOCKORBIT_OFFSETGALILEO },
      { CLOCKORBIT_SATQZSS,    'J', CLOCKORBIT_NUMQZSS,    CLOCKORBIT_OFFSETQZSS },
      { CLOCKORBIT_SATBDS,     'C', CLOCKORBIT_NUMBDS,     CLOCKORBIT_OFFSETBDS },
    };
    for (const SysInfo& si : sysInfos) {
      unsigned s = si.sysIdx;
      if (!_antennaReceived[s]) {
        continue; // no Antenna message received for this system since the last check
      }
      _antennaReceived[s] = false;
      if (_antenna.SatelliteMask[s] == 0) {
        continue; // no satellites in this system's antenna data
      }
      for (unsigned k = 0; k < si.numSat; k++) {
        if (!((_antenna.SatelliteMask[s] >> (63 - k)) & 1ULL)) {
          continue;
        }
        unsigned i = si.offset + k;
        if (_antennaSentTime[i].valid() &&
            _antenna.SSRProviderID[s]       == _antennaSentProviderID[i] &&
            _antenna.SatelliteAntennaIOD[s] == _antennaSentIOD[i] &&
            _lastTime - _antennaSentTime[i] <  ANT_REFRESH_SEC) {
          continue; // unchanged retransmission, emitted recently
        }
        _antennaSentProviderID[i] = _antenna.SSRProviderID[s];
        _antennaSentIOD[i]        = _antenna.SatelliteAntennaIOD[s];
        _antennaSentTime[i]       = _lastTime;
        int num  = k + 1; // PRN within this system, DF394 MSB-first convention
        int flag = 0;
        switch (si.sysChar) {
        case 'G': flag = t_eph::LNAV;    break;
        case 'R': flag = t_eph::FDMA_M;  break;
        case 'E': flag = t_eph::INAV;    break;
        case 'J': flag = t_eph::LNAV;    break;
        case 'C': flag = (num < 6 || (num > 58 && num < 63)) ? t_eph::D2 : t_eph::D1; break;
        }

        t_satAntenna satAntenna;
        satAntenna._prn.set(si.sysChar, num, flag);
        satAntenna._staID                     = _staID.toStdString();
        satAntenna._time                      = _lastTime;
        satAntenna._ssrProviderID             = _antenna.SSRProviderID[s];
        satAntenna._satelliteAntennaIOD       = _antenna.SatelliteAntennaIOD[s];
        satAntenna._phaseCenterInfoInd        = _antenna.PhaseCenterInformationIndicator[s];
        satAntenna._groupDelayInfoInd         = _antenna.GroupDelayInformationIndicator[s];
        satAntenna._nadirAngleDependentCorrInd = _antenna.NadirAngleDependentCorrectionIndicator[s];
        satAntenna._maximumOffNadirAngle      = _antenna.maximumOffNadirAngle[s];
        satAntenna._nadirAngleRangeExtension  = _antenna.NadirAngleDependentCorrectionRangeExtension[s];

        const SsrCorr::Antenna::SatellitePart& satPart = _antenna.Sat[i];
        for (unsigned f = 0; f < ANT_MAXFREQUENCIES; f++) {
          if (!((satPart.GnssFrequencyMask >> (ANT_MAXFREQUENCIES - 1 - f)) & 1U)) {
            continue;
          }
          t_frqAntenna frqAntenna;
          frqAntenna._frqType = _ssrCorr->antFreqIndexToStr(si.sysChar, f);
          if (frqAntenna._frqType.empty()) {
            continue; // reserved/unmapped DF+018 bit position
          }
          frqAntenna._nadirCorrectionIndicator = satPart.Freq[f].NadirCorrectionIndicator;
          frqAntenna._nadirCorrection          = satPart.Freq[f].NadirCorrection;
          if (satAntenna._nadirAngleDependentCorrInd) {
            unsigned count = satAntenna._maximumOffNadirAngle + 1;
            for (unsigned deg = 0; deg < count && deg < ANT_MAXNADIRDEGREES; deg++) {
              frqAntenna._nadirAngleCorrection.push_back(satPart.Freq[f].NadirAngleCorrection[deg]);
            }
          }
          satAntenna._freq.push_back(frqAntenna);
        }
        _satAntennas[_lastTime].append(satAntenna);
      }
    }
  }

  // Dump all older epochs
  // ---------------------
  dumpEpochs(false);
}

// Emit and write the collected corrections of all epochs older than
// _lastTime (all epochs if all is set, e.g. at the end of a replay)
////////////////////////////////////////////////////////////////////////////
void RTCM3coDecoder::dumpEpochs(bool all) {
  QMutableMapIterator<bncTime, QList<t_orbCorr> > itOrb(_orbCorrections);
  while (itOrb.hasNext()) {
    itOrb.next();
    if (all || itOrb.key() < _lastTime) {
      emit newOrbCorrections(itOrb.value());
      t_orbCorr::writeEpoch(_out, itOrb.value());
      itOrb.remove();
    }
  }
  QMutableMapIterator<bncTime, QList<t_clkCorr> > itClk(_clkCorrections);
  while (itClk.hasNext()) {
    itClk.next();
    if (all || itClk.key() < _lastTime) {
      emit newClkCorrections(itClk.value());
      t_clkCorr::writeEpoch(_out, itClk.value());
      itClk.remove();
    }
  }
  QMutableMapIterator<bncTime, QList<t_satCodeBias> > itCB(_codeBiases);
  while (itCB.hasNext()) {
    itCB.next();
    if (all || itCB.key() < _lastTime) {
      emit newCodeBiases(itCB.value());
      t_satCodeBias::writeEpoch(_out, itCB.value());
      itCB.remove();
    }
  }
  QMutableMapIterator<bncTime, QList<t_satPhaseBias> > itPB(_phaseBiases);
  while (itPB.hasNext()) {
    itPB.next();
    if (all || itPB.key() < _lastTime) {
      emit newPhaseBiases(itPB.value());
      t_satPhaseBias::writeEpoch(_out, itPB.value());
      itPB.remove();
    }
  }
  QMutableMapIterator<bncTime, t_vTec> itTec(_vTecMap);
  while (itTec.hasNext()) {
    itTec.next();
    if (all || itTec.key() < _lastTime) {
      emit newTec(itTec.value());
      t_vTec::write(_out, itTec.value());
      itTec.remove();
    }
  }
  QMutableMapIterator<bncTime, t_metaData> itMD(_metaDataMap);
  while (itMD.hasNext()) {
    itMD.next();
    if (all || itMD.key() < _lastTime) {
      emit newMetaData(itMD.value());
      t_metaData::write(_out, itMD.value());
      itMD.remove();
    }
  }
  QMutableMapIterator<bncTime, QList<t_satAntenna> > itAnt(_satAntennas);
  while (itAnt.hasNext()) {
    itAnt.next();
    if (all || itAnt.key() < _lastTime) {
      emit newSatAntennas(itAnt.value());
      t_satAntenna::writeEpoch(_out, itAnt.value());
      itAnt.remove();
    }
  }
  if (all && _out) {
    _out->flush();
  }
}

//
////////////////////////////////////////////////////////////////////////////
void RTCM3coDecoder::checkProviderID() {

  if (_clkOrb.SSRProviderID == 0 && _clkOrb.SSRSolutionID == 0 && _clkOrb.SSRIOD == 0) {
    return;
  }

  int newProviderID[3];
  newProviderID[0] = _clkOrb.SSRProviderID;
  newProviderID[1] = _clkOrb.SSRSolutionID;
  newProviderID[2] = _clkOrb.SSRIOD;
  QString newProviderIDStr = QString(" [SSR Provider ID: %1 SSR Solution ID: %2 SSR IOD: %3]: ")
      .arg(newProviderID[0]).arg(newProviderID[1]).arg(newProviderID[2]);

  bool alreadySet = false;
  bool different  = false;

  for (unsigned ii = 0; ii < 3; ii++) {
    if (_providerID[ii] != -1) {
      alreadySet = true;
    }
    if (_providerID[ii] != newProviderID[ii]) {
      different = true;
    }
    _providerID[ii] = newProviderID[ii];
  }

  if (alreadySet && different) {
    emit newMessage("RTCM3coDecoder: SSR Provider or Service changed " +  newProviderIDStr.toLatin1() + _staID.toLatin1(), true);
    emit providerIDChanged(_staID);
  }
}

// Check corrections
////////////////////////////////////////////////////////////////////////////
bool RTCM3coDecoder::corrIsOutOfRange(const SsrCorr::ClockOrbit::SatData& coSat) {

  QString ssrParStr;
  QString ssrParValue;
  bool corrIsOutOfRange = false;

  switch (_type) {
    // ======== //
    // IGS SSR  //
    // ======== //
    case  IGSssr:
      if (coSat.Clock.DeltaA0 < -209.7151 ||
          coSat.Clock.DeltaA0 > +209.7151)   {
        ssrParStr = "Clock::DeltaA0";
        ssrParValue = QString::number(coSat.Clock.DeltaA0, 'f', 4);
        corrIsOutOfRange = true;
      }
      if (coSat.Clock.DeltaA1 < -1.048575 ||
          coSat.Clock.DeltaA1 > +1.048575)  {
        ssrParStr = "Clock::DeltaA1";
        ssrParValue = QString::number(coSat.Clock.DeltaA1, 'f', 6);
        corrIsOutOfRange = true;
      }
      if (coSat.Clock.DeltaA2 < -1.3421772 ||
          coSat.Clock.DeltaA2 > +1.3421772) {
        ssrParStr = "Clock::DeltaA2";
        ssrParValue = QString::number(coSat.Clock.DeltaA2, 'f', 7);
        corrIsOutOfRange = true;
      }

      if (coSat.Orbit.DeltaRadial   < -209.7151 ||
          coSat.Orbit.DeltaRadial   > +209.7151) {
        ssrParStr = "Orbit::DeltaRadial";
        ssrParValue = QString::number(coSat.Orbit.DeltaRadial, 'f', 4);
        corrIsOutOfRange = true;
      }

      if (coSat.Orbit.DeltaAlongTrack < -209.7148 ||
          coSat.Orbit.DeltaAlongTrack > +209.7148) {
        ssrParStr = "Orbit::DeltaAlongTrack";
        ssrParValue = QString::number(coSat.Orbit.DeltaAlongTrack, 'f', 4);
        corrIsOutOfRange = true;
      }
      if (coSat.Orbit.DeltaCrossTrack < -209.7148 ||
          coSat.Orbit.DeltaCrossTrack > +209.7148) {
        ssrParStr = "Orbit::DeltaCrossTrack";
        ssrParValue = QString::number(coSat.Orbit.DeltaCrossTrack, 'f', 4);
        corrIsOutOfRange = true;
      }

      if (coSat.Orbit.DotDeltaRadial < -1.048575 ||
          coSat.Orbit.DotDeltaRadial > +1.048575) {
        ssrParStr = "Orbit::DotDeltaRadial";
        ssrParValue = QString::number(coSat.Orbit.DotDeltaRadial, 'f', 6);
        corrIsOutOfRange = true;
      }
      if (coSat.Orbit.DotDeltaAlongTrack < -1.048572 ||
          coSat.Orbit.DotDeltaAlongTrack > +1.048572) {
        ssrParStr = "Orbit::DotDeltaAlongTrack";
        ssrParValue = QString::number(coSat.Orbit.DotDeltaAlongTrack, 'f', 6);
        corrIsOutOfRange = true;
      }
      if (coSat.Orbit.DotDeltaCrossTrack < -1.048572 ||
          coSat.Orbit.DotDeltaCrossTrack > +1.048572) {
        ssrParStr = "Orbit::DotDeltaCrossTrack";
        ssrParValue = QString::number(coSat.Orbit.DotDeltaCrossTrack, 'f', 6);
        corrIsOutOfRange = true;
      }
      break;
    //==========//
    // RTCM SSR //
    // =========//
    case RTCMssr:
    case RTCMnewssr:
      if (coSat.Clock.DeltaA0 < -209.7151 ||
          coSat.Clock.DeltaA0 > +209.7151)   {
        ssrParStr = "Clock::DeltaA0";
        ssrParValue = QString::number(coSat.Clock.DeltaA0, 'f', 4);
        corrIsOutOfRange = true;
      }
      if (coSat.Clock.DeltaA1 < -1.048575 ||
          coSat.Clock.DeltaA1 > +1.048575)  {
        ssrParStr = "Clock::DeltaA1";
        ssrParValue = QString::number(coSat.Clock.DeltaA1, 'f', 6);
        corrIsOutOfRange = true;
      }
      if (coSat.Clock.DeltaA2 < -1.34217726 ||
          coSat.Clock.DeltaA2 > +1.34217726) {
        ssrParStr = "Clock::DeltaA2";
        ssrParValue = QString::number(coSat.Clock.DeltaA2, 'f', 8);
        corrIsOutOfRange = true;
      }

      if (coSat.Orbit.DeltaRadial   < -209.7151 ||
          coSat.Orbit.DeltaRadial   > +209.7151) {
        ssrParStr = "Orbit::DeltaRadial";
        ssrParValue = QString::number(coSat.Orbit.DeltaRadial, 'f', 4);
        corrIsOutOfRange = true;
      }

      if (coSat.Orbit.DeltaAlongTrack < -209.7148 ||
          coSat.Orbit.DeltaAlongTrack > +209.7148) {
        ssrParStr = "Orbit::DeltaAlongTrack";
        ssrParValue = QString::number(coSat.Orbit.DeltaAlongTrack, 'f', 4);
        corrIsOutOfRange = true;
      }
      if (coSat.Orbit.DeltaCrossTrack < -209.7148 ||
          coSat.Orbit.DeltaCrossTrack > +209.7148) {
        ssrParStr = "Orbit::DeltaCrossTrack";
        ssrParValue = QString::number(coSat.Orbit.DeltaCrossTrack, 'f', 4);
        corrIsOutOfRange = true;
      }

      if (coSat.Orbit.DotDeltaRadial < -1.048575 ||
          coSat.Orbit.DotDeltaRadial > +1.048575) {
        ssrParStr = "Orbit::DotDeltaRadial";
        ssrParValue = QString::number(coSat.Orbit.DotDeltaRadial, 'f', 6);
        corrIsOutOfRange = true;
      }
      if (coSat.Orbit.DotDeltaAlongTrack < -1.048572 ||
          coSat.Orbit.DotDeltaAlongTrack > +1.048572) {
        ssrParStr = "Orbit::DotDeltaAlongTrack";
        ssrParValue = QString::number(coSat.Orbit.DotDeltaAlongTrack, 'f', 6);
        corrIsOutOfRange = true;
      }
      if (coSat.Orbit.DotDeltaCrossTrack < -1.048572 ||
          coSat.Orbit.DotDeltaCrossTrack > +1.048572) {
        ssrParStr = "Orbit::DotDeltaCrossTrack";
        ssrParValue = QString::number(coSat.Orbit.DotDeltaCrossTrack, 'f', 6);
        corrIsOutOfRange = true;
      }
      break;
  }

  if (corrIsOutOfRange) {
    emit newMessage("RTCM3coDecoder: Correction " + ssrParStr.toLatin1()
        + " (" + ssrParValue.toLatin1() + ") "
        + "is out of range "  + _staID.toLatin1(), true);
  }

  return corrIsOutOfRange;
}

//
////////////////////////////////////////////////////////////////////////////
void RTCM3coDecoder::setEpochTime() {

  // _phaseBias is no longer wiped between messages (kept alive so a later
  // Extended Phase Bias message can correlate against it), so
  // NumberOfSat[s] > 0 alone no longer means "phase bias was just decoded
  // this cycle" - it can be stale from many messages ago. Gate each
  // phase-bias fallback below on the same per-system freshness tracking
  // sendResults() uses (_phaseBiasSentEpoch), so a stale leftover value
  // never gets picked as this cycle's epoch.
  bncTime prevTime = _lastTime;
  _lastTime.reset();

  double epoSecGPS  = -1.0;
  double epoSecGlo  = -1.0;
  double epoSecGal  = -1.0;
  double epoSecQzss = -1.0;
  double epoSecSbas = -1.0;
  double epoSecBds  = -1.0;
  if      (_clkOrb.NumberOfSat[CLOCKORBIT_SATGPS] > 0) {
    epoSecGPS = _clkOrb.EpochTime[CLOCKORBIT_SATGPS];        // 0 .. 604799 s
  }
  else if (_codeBias.NumberOfSat[CLOCKORBIT_SATGPS] > 0) {
    epoSecGPS = _codeBias.EpochTime[CLOCKORBIT_SATGPS];      // 0 .. 604799 s
  }
  else if (_phaseBias.NumberOfSat[CLOCKORBIT_SATGPS] > 0 &&
           _phaseBias.EpochTime[CLOCKORBIT_SATGPS] != _phaseBiasSentEpoch[CLOCKORBIT_SATGPS]) {
    epoSecGPS = _phaseBias.EpochTime[CLOCKORBIT_SATGPS];     // 0 .. 604799 s
  }
  else if (_vTEC.NumLayers > 0) {
    epoSecGPS = _vTEC.EpochTime;                             // 0 .. 604799 s
  }
  else if (_clkOrb.NumberOfSat[CLOCKORBIT_SATGLONASS] > 0) {
    epoSecGlo = _clkOrb.EpochTime[CLOCKORBIT_SATGLONASS];    // 0 .. 86399 s
  }
  else if (_codeBias.NumberOfSat[CLOCKORBIT_SATGLONASS] > 0) {
    epoSecGlo = _codeBias.EpochTime[CLOCKORBIT_SATGLONASS];  // 0 .. 86399 s
  }
  else if (_phaseBias.NumberOfSat[CLOCKORBIT_SATGLONASS] > 0 &&
           _phaseBias.EpochTime[CLOCKORBIT_SATGLONASS] != _phaseBiasSentEpoch[CLOCKORBIT_SATGLONASS]) {
    epoSecGlo = _phaseBias.EpochTime[CLOCKORBIT_SATGLONASS]; // 0 .. 86399 s
  }
  else if (_clkOrb.NumberOfSat[CLOCKORBIT_SATGALILEO] > 0) {
    epoSecGal = _clkOrb.EpochTime[CLOCKORBIT_SATGALILEO];
  }
  else if (_codeBias.NumberOfSat[CLOCKORBIT_SATGALILEO] > 0) {
    epoSecGal = _codeBias.EpochTime[CLOCKORBIT_SATGALILEO];
  }
  else if (_phaseBias.NumberOfSat[CLOCKORBIT_SATGALILEO] > 0 &&
           _phaseBias.EpochTime[CLOCKORBIT_SATGALILEO] != _phaseBiasSentEpoch[CLOCKORBIT_SATGALILEO]) {
    epoSecGal = _phaseBias.EpochTime[CLOCKORBIT_SATGALILEO];
  }
  else if (_clkOrb.NumberOfSat[CLOCKORBIT_SATQZSS] > 0) {
    epoSecQzss = _clkOrb.EpochTime[CLOCKORBIT_SATQZSS];
  }
  else if (_codeBias.NumberOfSat[CLOCKORBIT_SATQZSS] > 0) {
    epoSecQzss = _codeBias.EpochTime[CLOCKORBIT_SATQZSS];
  }
  else if (_phaseBias.NumberOfSat[CLOCKORBIT_SATQZSS] > 0 &&
           _phaseBias.EpochTime[CLOCKORBIT_SATQZSS] != _phaseBiasSentEpoch[CLOCKORBIT_SATQZSS]) {
    epoSecQzss = _phaseBias.EpochTime[CLOCKORBIT_SATQZSS];
  }
  else if (_clkOrb.NumberOfSat[CLOCKORBIT_SATSBAS] > 0) {
    epoSecSbas = _clkOrb.EpochTime[CLOCKORBIT_SATSBAS];
  }
  else if (_codeBias.NumberOfSat[CLOCKORBIT_SATSBAS] > 0) {
    epoSecSbas = _codeBias.EpochTime[CLOCKORBIT_SATSBAS];
  }
  else if (_phaseBias.NumberOfSat[CLOCKORBIT_SATSBAS] > 0 &&
           _phaseBias.EpochTime[CLOCKORBIT_SATSBAS] != _phaseBiasSentEpoch[CLOCKORBIT_SATSBAS]) {
    epoSecSbas = _phaseBias.EpochTime[CLOCKORBIT_SATSBAS];
  }
  else if (_clkOrb.NumberOfSat[CLOCKORBIT_SATBDS] > 0) {
    epoSecBds = _clkOrb.EpochTime[CLOCKORBIT_SATBDS];
  }
  else if (_codeBias.NumberOfSat[CLOCKORBIT_SATBDS] > 0) {
    epoSecBds = _codeBias.EpochTime[CLOCKORBIT_SATBDS];
  }
  else if (_phaseBias.NumberOfSat[CLOCKORBIT_SATBDS] > 0 &&
           _phaseBias.EpochTime[CLOCKORBIT_SATBDS] != _phaseBiasSentEpoch[CLOCKORBIT_SATBDS]) {
    epoSecBds = _phaseBias.EpochTime[CLOCKORBIT_SATBDS];
  }

  // Retrieve current time
  // ---------------------
  int    currentWeek = 0;
  double currentSec  = 0.0;
  currentGPSWeeks(currentWeek, currentSec);
  bncTime currentTime(currentWeek, currentSec);

  // Set _lastTime close to currentTime
  // ----------------------------------
  if (_type == IGSssr) {
    if      (epoSecGPS  != -1) _igsRefEpoSec = epoSecGPS;
    else if (epoSecGal  != -1) _igsRefEpoSec = epoSecGal;
    else if (epoSecQzss != -1) _igsRefEpoSec = epoSecQzss;
    else if (epoSecSbas != -1) _igsRefEpoSec = epoSecSbas;
  }

  if      (epoSecGPS != -1) {
    _lastTime.set(currentWeek, epoSecGPS);
  }
  else if (epoSecGlo != -1) {
    QDate date = dateAndTimeFromGPSweek(currentTime.gpsw(), currentTime.gpssec()).date();
    double leap = gnumleap(date.year(), date.month(), date.day());
    // IGS-SSR: the SSR Epoch Time (IDF003, 20 bit) is given in one continuous
    // time scale for all GNSS, i.e. GPS seconds of week also for GLONASS
    // (IGS SSR v1.00) - no conversion. Old encoders (e.g. BNC before
    // 2026-09-30) wrote UTC seconds of day instead: recognised by comparison
    // with the GPS/Galileo epoch of the same stream and converted, with a
    // warning, during a transition period.
    if (_type == IGSssr && _igsRefEpoSec >= 0.0 &&
        igsLegacyEpoch(epoSecGlo, fmod(_igsRefEpoSec - leap + 7 * 86400.0, 86400.0), 86400.0)) {
      double sow = floor(_igsRefEpoSec / 86400.0) * 86400.0 + epoSecGlo + leap;
      if      (sow - _igsRefEpoSec >  43200.0) sow -= 86400.0;
      else if (_igsRefEpoSec - sow >  43200.0) sow += 86400.0;
      epoSecGlo = sow;
      bncTime& warned = _legacyEpochWarned['R'];
      if (!warned.valid() || currentTime - warned >= 3600.0) {
        warned = currentTime;
        emit newMessage(_staID.toLatin1() + ": IGS-SSR GLONASS epoch time in outdated convention "
                        "(UTC seconds of day instead of GPS seconds of week, IGS SSR v1.00 IDF003) "
                        "detected and converted - the SSR encoder should be updated", true);
      }
    }
    if (_type == RTCMssr || _type == RTCMnewssr) {
      // Same GLONASS epoch wire convention (UTC+3h) as RTCMssr
      epoSecGlo = epoSecGlo - 3 * 3600 + leap;
    }
    _lastTime.set(currentWeek, epoSecGlo);
  }
  else if (epoSecGal != -1) {
    _lastTime.set(currentWeek, epoSecGal);
  }
  else if (epoSecQzss != -1) {
    _lastTime.set(currentWeek, epoSecQzss);
  }
  else if (epoSecSbas != -1) {
    _lastTime.set(currentWeek, epoSecSbas);
  }
  else if (epoSecBds != -1) {
    // RTCM-SSR: BDT; IGS-SSR: GPS time scale for all GNSS (IDF003). Old
    // IGS-SSR encoders wrote BDT: recognised and converted as for GLONASS.
    bool bdt = (_type != IGSssr);
    if (_type == IGSssr && _igsRefEpoSec >= 0.0 &&
        igsLegacyEpoch(epoSecBds, fmod(_igsRefEpoSec - 14.0 + 604800.0, 604800.0), 604800.0)) {
      bdt = true;
      bncTime& warned = _legacyEpochWarned['C'];
      if (!warned.valid() || currentTime - warned >= 3600.0) {
        warned = currentTime;
        emit newMessage(_staID.toLatin1() + ": IGS-SSR BDS epoch time in outdated convention "
                        "(BDT instead of GPS seconds of week, IGS SSR v1.00 IDF003) "
                        "detected and converted - the SSR encoder should be updated", true);
      }
    }
    if (bdt) {
      epoSecBds += 14.0;
      if (epoSecBds > 604800.0) {
        epoSecBds -= 7.0*24.0*60.0*60.0;
      }
    }
    _lastTime.set(currentWeek, epoSecBds);
  }
  // Some message types (e.g. Metadata) carry no epoch field of their own,
  // and none of the above found anything fresh to use either - fall back
  // to the last known valid time rather than leaving _lastTime invalid
  // (which would silently drop the message's output entirely).
  else if (prevTime.valid()) {
    _lastTime = prevTime;
  }

  if (_lastTime.valid()) {
    double maxDiff = 12 * 3600.0;
    while (_lastTime < currentTime - maxDiff) {
      _lastTime = _lastTime + maxDiff;
    }
    while (_lastTime > currentTime + maxDiff) {
      _lastTime = _lastTime - maxDiff;
    }
  }
}

// IGS-SSR: true if value (seconds, periodic with period) matches the epoch
// expected in the outdated convention (legacyRef) better than the GPS time
// reference of the stream, within the spread of epochs of one update cycle
////////////////////////////////////////////////////////////////////////////
bool RTCM3coDecoder::igsLegacyEpoch(double value, double legacyRef, double period) const {
  auto dist = [period](double a, double b) {
    double d = fmod(fabs(a - b), period);
    return std::min(d, period - d);
  };
  double dSpec   = dist(value, fmod(_igsRefEpoSec, period));
  double dLegacy = dist(value, legacyRef);
  return dLegacy < dSpec && dLegacy <= 30.0;
}

// Satellite numbers on the wire can exceed BNC's supported range (e.g. 6-bit
// IDs up to 63, while t_prn supports G01-G32). They are passed on unchanged,
// but t_prn::toInt() maps them to the unused index 0, so they are not usable
// for per-satellite processing - report that once per satellite.
////////////////////////////////////////////////////////////////////////////
void RTCM3coDecoder::checkPrnRange(const t_prn& prn) {
  if (prn.toInt() != 0) {
    return;
  }
  QString prnStr = QString::fromStdString(prn.toString());
  if (!_prnOutOfRangeLogged.contains(prnStr)) {
    _prnOutOfRangeLogged.insert(prnStr);
    emit newMessage(QString("%1: satellite %2 outside the range supported by BNC - not usable for processing")
                    .arg(_staID).arg(prnStr).toLatin1(), true);
  }
}

// All Satellite Antenna messages of one GNSS must use the same Satellite
// Antenna IOD (a satellite set may be split over several messages). During
// a legitimate IOD change old and new IOD are both seen for a while, until
// every message has been sent with the new one - but the IOD never returns
// to a replaced value within 64 days (uniqueness per SSR Provider ID), so a
// switch back to the replaced IOD within that period reveals messages of the
// same GNSS using different IODs. Reported once per GNSS and IOD pair; the
// data itself is not changed (PPP checks each satellite's IOD individually).
////////////////////////////////////////////////////////////////////////////
void RTCM3coDecoder::checkAntennaIOD(unsigned s) {
  const double MAX_AGE_SEC = 64.0 * 86400.0;
  unsigned int iod      = _antenna.SatelliteAntennaIOD[s];
  unsigned int provider = _antenna.SSRProviderID[s];

  int    currentWeek = 0;
  double currentSec  = 0.0;
  currentGPSWeeks(currentWeek, currentSec);
  bncTime currentTime(currentWeek, currentSec);

  if (!_antennaIODKnown[s] || provider != _antennaIODProvider[s]) {
    _antennaIODKnown[s]    = true;
    _antennaIODProvider[s] = provider;
    _antennaIODCur[s]      = iod;
    _antennaIODPrevTime[s].reset(); // no replaced IOD yet
    return;
  }
  if (iod == _antennaIODCur[s]) {
    return;
  }
  if (_antennaIODPrevTime[s].valid() && iod == _antennaIODPrev[s] &&
      currentTime - _antennaIODPrevTime[s] < MAX_AGE_SEC) {
    const char sysChars[CLOCKORBIT_SATNUM] = {'G', 'R', 'E', 'J', 'S', 'C'};
    char sys = (s < CLOCKORBIT_SATNUM) ? sysChars[s] : '?';
    QString key = QString("%1_%2_%3").arg(sys).arg(_antennaIODCur[s]).arg(iod);
    if (!_antennaIODLogged.contains(key)) {
      _antennaIODLogged.insert(key);
      emit newMessage(QString("%1: GNSS %2 Satellite Antenna IOD switches back %3 -> %4"
                              " - messages of one GNSS must use the same IOD")
                      .arg(_staID).arg(sys).arg(_antennaIODCur[s]).arg(iod).toLatin1(), true);
    }
  }
  _antennaIODPrev[s]     = _antennaIODCur[s];
  _antennaIODPrevTime[s] = currentTime;
  _antennaIODCur[s]      = iod;
}
