Index: trunk/BNC/src/RTCM3/RTCM3coDecoder.cpp
===================================================================
--- trunk/BNC/src/RTCM3/RTCM3coDecoder.cpp	(revision 11041)
+++ trunk/BNC/src/RTCM3/RTCM3coDecoder.cpp	(revision 11047)
@@ -105,5 +105,13 @@
 
   for (unsigned s = 0; s < CLOCKORBIT_SATNUM; s++) {
-    _antennaSentIOD[s] = UINT_MAX; // "never sent"
+    _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")
   }
 
@@ -203,4 +211,13 @@
     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;
 
@@ -240,4 +257,12 @@
 
       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
 
@@ -342,4 +367,5 @@
       t_orbCorr orbCorr;
       orbCorr._prn.set(sys, num, flag);
+      checkPrnRange(orbCorr._prn);
       orbCorr._staID     = _staID.toStdString();
       orbCorr._iod       = _clkOrb.Sat[ii].IOD;
@@ -378,4 +404,5 @@
       t_clkCorr clkCorr;
       clkCorr._prn.set(sys, _clkOrb.Sat[ii].ID, flag);
+      checkPrnRange(clkCorr._prn);
       clkCorr._staID      = _staID.toStdString();
       clkCorr._time       = _lastTime;
@@ -470,7 +497,10 @@
     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];
@@ -560,7 +590,10 @@
     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;
@@ -649,9 +682,26 @@
   // _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. _antennaSentIOD tracks the last SatelliteAntennaIOD actually
-  // emitted per system, so unrelated messages (and unchanged retransmissions
-  // of the same antenna data) don't cause duplicate output - the same
-  // freshness problem _phaseBiasSentEpoch solves for phase biases.
+  // 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[] = {
@@ -664,9 +714,10 @@
     for (const SysInfo& si : sysInfos) {
       unsigned s = si.sysIdx;
-      if (_antenna.SatelliteAntennaIOD[s] == _antennaSentIOD[s]) {
-        continue; // nothing new for this system since the last emit
-      }
+      if (!_antennaReceived[s]) {
+        continue; // no Antenna message received for this system since the last check
+      }
+      _antennaReceived[s] = false;
       if (_antenna.SatelliteMask[s] == 0) {
-        continue; // this system's antenna data was never decoded
+        continue; // no satellites in this system's antenna data
       }
       for (unsigned k = 0; k < si.numSat; k++) {
@@ -674,4 +725,14 @@
           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;
@@ -696,5 +757,5 @@
         satAntenna._nadirAngleRangeExtension  = _antenna.NadirAngleDependentCorrectionRangeExtension[s];
 
-        const SsrCorr::Antenna::SatellitePart& satPart = _antenna.Sat[si.offset + k];
+        const SsrCorr::Antenna::SatellitePart& satPart = _antenna.Sat[i];
         for (unsigned f = 0; f < ANT_MAXFREQUENCIES; f++) {
           if (!((satPart.GnssFrequencyMask >> (ANT_MAXFREQUENCIES - 1 - f)) & 1U)) {
@@ -718,5 +779,4 @@
         _satAntennas[_lastTime].append(satAntenna);
       }
-      _antennaSentIOD[s] = _antenna.SatelliteAntennaIOD[s];
     }
   }
@@ -1109,2 +1169,65 @@
   }
 }
+
+// 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;
+}
