RinexObservationWriter.java

/* Copyright 2023 Thales Alenia Space
 * Licensed to CS GROUP (CS) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * CS licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.orekit.files.rinex.observation;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;

import org.hipparchus.geometry.euclidean.threed.Vector3D;
import org.hipparchus.util.FastMath;
import org.orekit.annotation.DefaultDataContext;
import org.orekit.errors.OrekitException;
import org.orekit.errors.OrekitMessages;
import org.orekit.files.rinex.AppliedDCBS;
import org.orekit.files.rinex.AppliedPCVS;
import org.orekit.files.rinex.section.RinexComment;
import org.orekit.files.rinex.section.RinexLabels;
import org.orekit.gnss.ObservationTimeScale;
import org.orekit.gnss.ObservationType;
import org.orekit.gnss.SatInSystem;
import org.orekit.gnss.SatelliteSystem;
import org.orekit.time.AbsoluteDate;
import org.orekit.time.DateTimeComponents;
import org.orekit.time.TimeScale;
import org.orekit.time.TimeScalesFactory;

/** Writer for Rinex observation file.
 * <p>
 * As RINEX file are organized in batches of observations at some dates,
 * these observations are cached and a new batch is output only when
 * a new date appears when calling {@link #writeObservationDataSet(ObservationDataSet)}
 * or when the file is closed by calling the {@link #close() close} method.
 * Failing to call {@link #close() close} would imply the last batch
 * of measurements is not written. This is the reason why this class implements
 * {@link AutoCloseable}, so the {@link #close() close} method can be called automatically in
 * a {@code try-with-resources} statement.
 * </p>
 * @author Luc Maisonobe
 * @since 12.0
 */
public class RinexObservationWriter implements AutoCloseable {

    /** Index of label in header lines. */
    private static final int LABEL_INDEX = 60;

    /** Format for one 1 digit integer field. */
    private static final String ONE_DIGIT_INTEGER = "%1d";

    /** Format for one 2 digits integer field. */
    private static final String PADDED_TWO_DIGITS_INTEGER = "%02d";

    /** Format for one 2 digits integer field. */
    private static final String TWO_DIGITS_INTEGER = "%2d";

    /** Format for one 4 digits integer field. */
    private static final String PADDED_FOUR_DIGITS_INTEGER = "%04d";

    /** Format for one 3 digits integer field. */
    private static final String THREE_DIGITS_INTEGER = "%3d";

    /** Format for one 4 digits integer field. */
    private static final String FOUR_DIGITS_INTEGER = "%4d";

    /** Format for one 6 digits integer field. */
    private static final String SIX_DIGITS_INTEGER = "%6d";

    /** Format for one 8.3 digits float field. */
    private static final String EIGHT_THREE_DIGITS_FLOAT = "%8.3f";

    /** Format for one 8.5 digits float field. */
    private static final String EIGHT_FIVE_DIGITS_FLOAT = "%8.5f";

    /** Format for one 9.4 digits float field. */
    private static final String NINE_FOUR_DIGITS_FLOAT = "%9.4f";

    /** Format for one 10.3 digits float field. */
    private static final String TEN_THREE_DIGITS_FLOAT = "%10.3f";

    /** Format for one 11.7 digits float field. */
    private static final String ELEVEN_SEVEN_DIGITS_FLOAT = "%11.7f";

    /** Format for one 12.9 digits float field. */
    private static final String TWELVE_NINE_DIGITS_FLOAT = "%12.9f";

    /** Format for one 13.7 digits float field. */
    private static final String THIRTEEN_SEVEN_DIGITS_FLOAT = "%13.7f";

    /** Format for one 14.3 digits float field. */
    private static final String FOURTEEN_THREE_DIGITS_FLOAT = "%14.3f";

    /** Format for one 14.4 digits float field. */
    private static final String FOURTEEN_FOUR_DIGITS_FLOAT = "%14.4f";

    /** Format for one 15.12 digits float field. */
    private static final String FIFTEEN_TWELVE_DIGITS_FLOAT = "%15.12f";

    /** Threshold for considering measurements are at the sate time.
     * (we know the RINEX files encode dates with a resolution of 0.1µs)
     */
    private static final double EPS_DATE = 1.0e-8;

    /** Destination of generated output. */
    private final Appendable output;

    /** Output name for error messages. */
    private final String outputName;

    /** Time scale for writing dates. */
    private TimeScale timeScale;

    /** Saved header. */
    private RinexObservationHeader savedHeader;

    /** Saved comments. */
    private List<RinexComment> savedComments;

    /** Pending observations. */
    private final List<ObservationDataSet> pending;

    /** Line number. */
    private int lineNumber;

    /** Column number. */
    private int column;

    /** Simple constructor.
     * @param output destination of generated output
     * @param outputName output name for error messages
     */
    public RinexObservationWriter(final Appendable output, final String outputName) {
        this.output        = output;
        this.outputName    = outputName;
        this.savedHeader   = null;
        this.savedComments = Collections.emptyList();
        this.pending       = new ArrayList<>();
        this.lineNumber    = 0;
        this.column        = 0;
    }

    /** {@inheritDoc} */
    @Override
    public void close() throws IOException {
        processPending();
    }

    /** Write a complete observation file.
     * <p>
     * This method calls {@link #prepareComments(List)} and
     * {@link #writeHeader(RinexObservationHeader)} once and then loops on
     * calling {@link #writeObservationDataSet(ObservationDataSet)}
     * for all observation data sets in the file
     * </p>
     * @param rinexObservation Rinex observation file to write
     * @see #writeHeader(RinexObservationHeader)
     * @see #writeObservationDataSet(ObservationDataSet)
     * @exception IOException if an I/O error occurs.
     */
    @DefaultDataContext
    public void writeCompleteFile(final RinexObservation rinexObservation)
        throws IOException {
        prepareComments(rinexObservation.getComments());
        writeHeader(rinexObservation.getHeader());
        for (final ObservationDataSet observationDataSet : rinexObservation.getObservationDataSets()) {
            writeObservationDataSet(observationDataSet);
        }
    }

    /** Prepare comments to be emitted at specified lines.
     * @param comments comments to be emitted
     */
    public void prepareComments(final List<RinexComment> comments) {
        savedComments = comments;
    }

    /** Write header.
     * <p>
     * This method must be called exactly once at the beginning
     * (directly or by {@link #writeCompleteFile(RinexObservation)})
     * </p>
     * @param header header to write
     * @exception IOException if an I/O error occurs.
     */
    @DefaultDataContext
    public void writeHeader(final RinexObservationHeader header)
        throws IOException {

        // check header is written exactly once
        if (savedHeader != null) {
            throw new OrekitException(OrekitMessages.HEADER_ALREADY_WRITTEN, outputName);
        }
        savedHeader = header;
        lineNumber  = 1;

        final ObservationTimeScale observationTimeScale = header.getSatelliteSystem().getObservationTimeScale() != null ?
                                                          header.getSatelliteSystem().getObservationTimeScale() :
                                                          ObservationTimeScale.GPS;
        timeScale = observationTimeScale.getTimeScale(TimeScalesFactory.getTimeScales());

        // RINEX VERSION / TYPE
        outputField("%9.2f", header.getFormatVersion(), 9);
        outputField("",                 20, true);
        outputField("OBSERVATION DATA", 40, true);
        outputField(header.getSatelliteSystem().getKey(), 41);
        finishHeaderLine(RinexLabels.VERSION);

        // PGM / RUN BY / DATE
        outputField(header.getProgramName(), 20, true);
        outputField(header.getRunByName(),   40, true);
        final DateTimeComponents dtc = header.getCreationDateComponents();
        if (header.getFormatVersion() < 3.0 && dtc.getTime().getSecond() < 0.5) {
            outputField(PADDED_TWO_DIGITS_INTEGER, dtc.getDate().getDay(), 42);
            outputField('-', 43);
            outputField(dtc.getDate().getMonthEnum().getUpperCaseAbbreviation(), 46,  true);
            outputField('-', 47);
            outputField(PADDED_TWO_DIGITS_INTEGER, dtc.getDate().getYear() % 100, 49);
            outputField(PADDED_TWO_DIGITS_INTEGER, dtc.getTime().getHour(), 52);
            outputField(':', 53);
            outputField(PADDED_TWO_DIGITS_INTEGER, dtc.getTime().getMinute(), 55);
            outputField(header.getCreationTimeZone(), 58, true);
        } else {
            outputField(PADDED_FOUR_DIGITS_INTEGER, dtc.getDate().getYear(), 44);
            outputField(PADDED_TWO_DIGITS_INTEGER, dtc.getDate().getMonth(), 46);
            outputField(PADDED_TWO_DIGITS_INTEGER, dtc.getDate().getDay(), 48);
            outputField(PADDED_TWO_DIGITS_INTEGER, dtc.getTime().getHour(), 51);
            outputField(PADDED_TWO_DIGITS_INTEGER, dtc.getTime().getMinute(), 53);
            outputField(PADDED_TWO_DIGITS_INTEGER, (int) FastMath.rint(dtc.getTime().getSecond()), 55);
            outputField(header.getCreationTimeZone(), 59, false);
        }
        finishHeaderLine(RinexLabels.PROGRAM);

        // MARKER NAME
        outputField(header.getMarkerName(), 60, true);
        finishHeaderLine(RinexLabels.MARKER_NAME);

        // MARKER NUMBER
        if (header.getMarkerNumber() != null) {
            outputField(header.getMarkerNumber(), 20, true);
            finishHeaderLine(RinexLabels.MARKER_NUMBER);
        }

        // MARKER TYPE
        if (header.getFormatVersion() >= 2.20) {
            outputField(header.getMarkerType(), 20, true);
            finishHeaderLine(RinexLabels.MARKER_TYPE);
        }

        // OBSERVER / AGENCY
        outputField(header.getObserverName(), 20, true);
        outputField(header.getAgencyName(),   40, true);
        finishHeaderLine(RinexLabels.OBSERVER_AGENCY);

        // REC # / TYPE / VERS
        outputField(header.getReceiverNumber(),  20, true);
        outputField(header.getReceiverType(),    40, true);
        outputField(header.getReceiverVersion(), 60, true);
        finishHeaderLine(RinexLabels.REC_NB_TYPE_VERS);

        // ANT # / TYPE
        outputField(header.getAntennaNumber(), 20, true);
        outputField(header.getAntennaType(),   40, true);
        finishHeaderLine(RinexLabels.ANT_NB_TYPE);

        // APPROX POSITION XYZ
        writeHeaderLine(header.getApproxPos(), RinexLabels.APPROX_POSITION_XYZ);

        // ANTENNA: DELTA H/E/N
        if (!Double.isNaN(header.getAntennaHeight())) {
            outputField(FOURTEEN_FOUR_DIGITS_FLOAT, header.getAntennaHeight(),         14);
            outputField(FOURTEEN_FOUR_DIGITS_FLOAT, header.getEccentricities().getX(), 28);
            outputField(FOURTEEN_FOUR_DIGITS_FLOAT, header.getEccentricities().getY(), 42);
            finishHeaderLine(RinexLabels.ANTENNA_DELTA_H_E_N);
        }

        // ANTENNA: DELTA X/Y/Z
        writeHeaderLine(header.getAntennaReferencePoint(), RinexLabels.ANTENNA_DELTA_X_Y_Z);

        // ANTENNA: PHASECENTER
        if (header.getAntennaPhaseCenter() != null) {
            outputField(header.getPhaseCenterSystem().getKey(), 1);
            outputField("", 2, true);
            outputField(header.getObservationCode(), 5, true);
            outputField(NINE_FOUR_DIGITS_FLOAT,     header.getAntennaPhaseCenter().getX(), 14);
            outputField(FOURTEEN_FOUR_DIGITS_FLOAT, header.getAntennaPhaseCenter().getY(), 28);
            outputField(FOURTEEN_FOUR_DIGITS_FLOAT, header.getAntennaPhaseCenter().getZ(), 42);
            finishHeaderLine(RinexLabels.ANTENNA_PHASE_CENTER);
        }

        // ANTENNA: B.SIGHT XY
        writeHeaderLine(header.getAntennaBSight(), RinexLabels.ANTENNA_B_SIGHT_XYZ);

        // ANTENNA: ZERODIR AZI
        if (!Double.isNaN(header.getAntennaAzimuth())) {
            outputField(FOURTEEN_FOUR_DIGITS_FLOAT, FastMath.toDegrees(header.getAntennaAzimuth()), 14);
            finishHeaderLine(RinexLabels.ANTENNA_ZERODIR_AZI);
        }

        // ANTENNA: ZERODIR XYZ
        writeHeaderLine(header.getAntennaZeroDirection(), RinexLabels.ANTENNA_ZERODIR_XYZ);

        // OBS SCALE FACTOR
        if (FastMath.abs(header.getFormatVersion() - 2.20) < 0.001) {
            for (final SatelliteSystem system : SatelliteSystem.values()) {
                for (final ScaleFactorCorrection sfc : header.getScaleFactorCorrections(system)) {
                    if (sfc != null) {
                        outputField(SIX_DIGITS_INTEGER, (int) FastMath.round(sfc.getCorrection()), 6);
                        outputField(SIX_DIGITS_INTEGER, sfc.getTypesObsScaled().size(), 12);
                        for (int i = 0; i < sfc.getTypesObsScaled().size(); ++i) {
                            outputField(sfc.getTypesObsScaled().get(i).name(), 18 + 6 * i, false);
                        }
                        finishHeaderLine(RinexLabels.OBS_SCALE_FACTOR);
                    }
                }
            }
        }

        // CENTER OF MASS: XYZ
        writeHeaderLine(header.getCenterMass(), RinexLabels.CENTER_OF_MASS_XYZ);

        // DOI
        writeHeaderLine(header.getDoi(), RinexLabels.DOI);

        // LICENSE OF USE
        writeHeaderLine(header.getLicense(), RinexLabels.LICENSE);

        // STATION INFORMATION
        writeHeaderLine(header.getStationInformation(), RinexLabels.STATION_INFORMATION);

        // SYS / # / OBS TYPES
        for (Map.Entry<SatelliteSystem, List<ObservationType>> entry : header.getTypeObs().entrySet()) {
            if (header.getFormatVersion() < 3.0) {
                outputField(SIX_DIGITS_INTEGER, entry.getValue().size(), 6);
            } else {
                outputField(entry.getKey().getKey(), 1);
                outputField(THREE_DIGITS_INTEGER, entry.getValue().size(), 6);
            }
            for (final ObservationType observationType : entry.getValue()) {
                int next = column + (header.getFormatVersion() < 3.0 ? 6 : 4);
                if (next > LABEL_INDEX) {
                    // we need to set up a continuation line
                    finishHeaderLine(header.getFormatVersion() < 3.0 ?
                                     RinexLabels.NB_TYPES_OF_OBSERV :
                                     RinexLabels.SYS_NB_TYPES_OF_OBSERV);
                    outputField("", 6, true);
                    next = column + (header.getFormatVersion() < 3.0 ? 6 : 4);
                }
                outputField(observationType.name(), next, false);
            }
            finishHeaderLine(header.getFormatVersion() < 3.0 ?
                             RinexLabels.NB_TYPES_OF_OBSERV :
                             RinexLabels.SYS_NB_TYPES_OF_OBSERV);
        }

        // SIGNAL STRENGTH UNIT
        writeHeaderLine(header.getSignalStrengthUnit(), RinexLabels.SIGNAL_STRENGTH_UNIT);

        // INTERVAL
        if (!Double.isNaN(header.getInterval())) {
            outputField(TEN_THREE_DIGITS_FLOAT, header.getInterval(), 10);
            finishHeaderLine(RinexLabels.INTERVAL);
        }

        // TIME OF FIRST OBS
        final DateTimeComponents dtcFirst = header.getTFirstObs().getComponents(timeScale);
        outputField(SIX_DIGITS_INTEGER,          dtcFirst.getDate().getYear(), 6);
        outputField(SIX_DIGITS_INTEGER,          dtcFirst.getDate().getMonth(), 12);
        outputField(SIX_DIGITS_INTEGER,          dtcFirst.getDate().getDay(), 18);
        outputField(SIX_DIGITS_INTEGER,          dtcFirst.getTime().getHour(), 24);
        outputField(SIX_DIGITS_INTEGER,          dtcFirst.getTime().getMinute(), 30);
        outputField(THIRTEEN_SEVEN_DIGITS_FLOAT, dtcFirst.getTime().getSecond(), 43);
        outputField(observationTimeScale.name(), 51, false);
        finishHeaderLine(RinexLabels.TIME_OF_FIRST_OBS);

        // TIME OF LAST OBS
        if (!header.getTLastObs().equals(AbsoluteDate.FUTURE_INFINITY)) {
            final DateTimeComponents dtcLast = header.getTLastObs().getComponents(timeScale);
            outputField(SIX_DIGITS_INTEGER,          dtcLast.getDate().getYear(), 6);
            outputField(SIX_DIGITS_INTEGER,          dtcLast.getDate().getMonth(), 12);
            outputField(SIX_DIGITS_INTEGER,          dtcLast.getDate().getDay(), 18);
            outputField(SIX_DIGITS_INTEGER,          dtcLast.getTime().getHour(), 24);
            outputField(SIX_DIGITS_INTEGER,          dtcLast.getTime().getMinute(), 30);
            outputField(THIRTEEN_SEVEN_DIGITS_FLOAT, dtcLast.getTime().getSecond(), 43);
            outputField(observationTimeScale.name(), 51, false);
            finishHeaderLine(RinexLabels.TIME_OF_LAST_OBS);
        }

        // RCV CLOCK OFFS APPL
        if (header.getClkOffset() >= 0) {
            outputField(SIX_DIGITS_INTEGER, header.getClkOffset(), 6);
            finishHeaderLine(RinexLabels.RCV_CLOCK_OFFS_APPL);
        }

        // SYS / DCBS APPLIED
        for (final AppliedDCBS appliedDCBS : header.getListAppliedDCBS()) {
            outputField(appliedDCBS.getSatelliteSystem().getKey(),  1);
            outputField("",                                         2, true);
            outputField(appliedDCBS.getProgDCBS(),                 20, true);
            outputField(appliedDCBS.getSourceDCBS(),               60, true);
            finishHeaderLine(RinexLabels.SYS_DCBS_APPLIED);
        }

        // SYS / PCVS APPLIED
        for (final AppliedPCVS appliedPCVS : header.getListAppliedPCVS()) {
            outputField(appliedPCVS.getSatelliteSystem().getKey(),  1);
            outputField("",                                         2, true);
            outputField(appliedPCVS.getProgPCVS(),                 20, true);
            outputField(appliedPCVS.getSourcePCVS(),               60, true);
            finishHeaderLine(RinexLabels.SYS_PCVS_APPLIED);
        }

        // SYS / SCALE FACTOR
        if (header.getFormatVersion() >= 3.0) {
            for (final SatelliteSystem system : SatelliteSystem.values()) {
                for (final ScaleFactorCorrection sfc : header.getScaleFactorCorrections(system)) {
                    if (sfc != null) {
                        outputField(system.getKey(), 1);
                        outputField("", 2, true);
                        outputField(FOUR_DIGITS_INTEGER, (int) FastMath.rint(sfc.getCorrection()), 6);
                        if (sfc.getTypesObsScaled().size() < header.getTypeObs().get(system).size()) {
                            outputField("", 8, true);
                            outputField(TWO_DIGITS_INTEGER,  sfc.getTypesObsScaled().size(), 10);
                            for (ObservationType observationType : sfc.getTypesObsScaled()) {
                                int next = column + 4;
                                if (next > LABEL_INDEX) {
                                    // we need to set up a continuation line
                                    finishHeaderLine(RinexLabels.SYS_SCALE_FACTOR);
                                    outputField("", 10, true);
                                    next = column + 4;
                                }
                                outputField("", next - 3, true);
                                outputField(observationType.name(), next, true);
                            }
                        }
                        finishHeaderLine(RinexLabels.SYS_SCALE_FACTOR);
                    }
                }
            }
        }

        // SYS / PHASE SHIFT
        for (final PhaseShiftCorrection psc : header.getPhaseShiftCorrections()) {
            outputField(psc.getSatelliteSystem().getKey(), 1);
            outputField(psc.getTypeObs().name(), 5, false);
            outputField(EIGHT_FIVE_DIGITS_FLOAT, psc.getCorrection(), 14);
            if (!psc.getSatsCorrected().isEmpty()) {
                outputField(TWO_DIGITS_INTEGER, psc.getSatsCorrected().size(), 18);
                for (final SatInSystem sis : psc.getSatsCorrected()) {
                    int next = column + 4;
                    if (next > LABEL_INDEX) {
                        // we need to set up a continuation line
                        finishHeaderLine(RinexLabels.SYS_PHASE_SHIFT);
                        outputField("", 18, true);
                        next = column + 4;
                    }
                    outputField(sis.getSystem().getKey(), next - 2);
                    outputField(PADDED_TWO_DIGITS_INTEGER, sis.getTwoDigitsRinexPRN(), next);
                }
            }
            finishHeaderLine(RinexLabels.SYS_PHASE_SHIFT);
        }

        if (header.getFormatVersion() >= 3.01) {
            if (!header.getGlonassChannels().isEmpty()) {
                // GLONASS SLOT / FRQ #
                outputField(THREE_DIGITS_INTEGER, header.getGlonassChannels().size(), 3);
                outputField("", 4, true);
                for (final GlonassSatelliteChannel channel : header.getGlonassChannels()) {
                    int next = column + 7;
                    if (next > LABEL_INDEX) {
                        // we need to set up a continuation line
                        finishHeaderLine(RinexLabels.GLONASS_SLOT_FRQ_NB);
                        outputField("", 4, true);
                        next = column + 7;
                    }
                    outputField(channel.getSatellite().getSystem().getKey(), next - 6);
                    outputField(PADDED_TWO_DIGITS_INTEGER, channel.getSatellite().getPRN(), next - 4);
                    outputField(TWO_DIGITS_INTEGER, channel.getK(), next - 1);
                    outputField("", next, true);
                }
            }
            finishHeaderLine(RinexLabels.GLONASS_SLOT_FRQ_NB);
        }

        if (header.getFormatVersion() >= 3.0) {
            // GLONASS COD/PHS/BIS
            if (Double.isNaN(header.getC1cCodePhaseBias())) {
                outputField("", 13, true);
            } else {
                outputField(ObservationType.C1C.name(), 4, false);
                outputField("", 5, true);
                outputField(EIGHT_THREE_DIGITS_FLOAT, header.getC1cCodePhaseBias(), 13);
            }
            if (Double.isNaN(header.getC1pCodePhaseBias())) {
                outputField("", 26, true);
            } else {
                outputField(ObservationType.C1P.name(), 17, false);
                outputField("", 18, true);
                outputField(EIGHT_THREE_DIGITS_FLOAT, header.getC1pCodePhaseBias(), 26);
            }
            if (Double.isNaN(header.getC2cCodePhaseBias())) {
                outputField("", 39, true);
            } else {
                outputField(ObservationType.C2C.name(), 30, false);
                outputField("", 31, true);
                outputField(EIGHT_THREE_DIGITS_FLOAT, header.getC2cCodePhaseBias(), 39);
            }
            if (Double.isNaN(header.getC2pCodePhaseBias())) {
                outputField("", 52, true);
            } else {
                outputField(ObservationType.C2P.name(), 43, false);
                outputField("", 44, true);
                outputField(EIGHT_THREE_DIGITS_FLOAT, header.getC2pCodePhaseBias(), 52);
            }
            finishHeaderLine(RinexLabels.GLONASS_COD_PHS_BIS);
        }

        // LEAP SECONDS
        if (header.getLeapSeconds() > 0) {
            outputField(SIX_DIGITS_INTEGER, header.getLeapSeconds(), 6);
            if (header.getFormatVersion() >= 3.0) {
                outputField(SIX_DIGITS_INTEGER, header.getLeapSecondsFuture(),  12);
                outputField(SIX_DIGITS_INTEGER, header.getLeapSecondsWeekNum(), 18);
                outputField(SIX_DIGITS_INTEGER, header.getLeapSecondsDayNum(),  24);
            }
            finishHeaderLine(RinexLabels.LEAP_SECONDS);
        }

        // # OF SATELLITES
        if (header.getNbSat() >= 0) {
            outputField(SIX_DIGITS_INTEGER, header.getNbSat(), 6);
            finishHeaderLine(RinexLabels.NB_OF_SATELLITES);
        }

        // PRN / # OF OBS
        for (final Map.Entry<SatInSystem, Map<ObservationType, Integer>> entry1 : header.getNbObsPerSat().entrySet()) {
            final SatInSystem sis = entry1.getKey();
            outputField(sis.getSystem().getKey(), 4);
            outputField(PADDED_TWO_DIGITS_INTEGER, sis.getTwoDigitsRinexPRN(), 6);
            for (final Map.Entry<ObservationType, Integer> entry2 : entry1.getValue().entrySet()) {
                int next = column + 6;
                if (next > LABEL_INDEX) {
                    // we need to set up a continuation line
                    finishHeaderLine(RinexLabels.PRN_NB_OF_OBS);
                    outputField("", 6, true);
                    next = column + 6;
                }
                outputField(SIX_DIGITS_INTEGER, entry2.getValue(), next);
            }
            finishHeaderLine(RinexLabels.PRN_NB_OF_OBS);
        }

        // END OF HEADER
        writeHeaderLine("", RinexLabels.END);

    }

    /** Write one observation data set.
     * <p>
     * Note that this writers output only regular observations, so
     * the event flag is always set to 0
     * </p>
     * @param observationDataSet observation data set to write
     * @exception IOException if an I/O error occurs.
     */
    public void writeObservationDataSet(final ObservationDataSet observationDataSet)
        throws IOException {

        // check header has already been written
        if (savedHeader == null) {
            throw new OrekitException(OrekitMessages.HEADER_NOT_WRITTEN, outputName);
        }

        if (!pending.isEmpty() && observationDataSet.durationFrom(pending.get(0).getDate()) > EPS_DATE) {
            // the specified observation belongs to the next batch
            // we must process the current batch of pending observations
            processPending();
        }

        // add the observation to the pending list, so it is written later on
        pending.add(observationDataSet);

    }

    /** Process all pending measurements.
     * @exception IOException if an I/O error occurs.
     */
    private void processPending() throws IOException {

        if (!pending.isEmpty()) {

            // write the batch of pending observations
            if (savedHeader.getFormatVersion() < 3.0) {
                writePendingRinex2Observations();
            } else {
                writePendingRinex34Observations();
            }

            // prepare for next batch
            pending.clear();

        }

    }

    /** Write one observation data set in RINEX 2 format.
     * @exception IOException if an I/O error occurs.
     */
    public void writePendingRinex2Observations() throws IOException {

        final ObservationDataSet first = pending.get(0);

        // EPOCH/SAT
        final DateTimeComponents dtc = first.getDate().getComponents(timeScale);
        outputField("",  1, true);
        outputField(PADDED_TWO_DIGITS_INTEGER,   dtc.getDate().getYear() % 100,    3);
        outputField("",  4, true);
        outputField(TWO_DIGITS_INTEGER,          dtc.getDate().getMonth(),         6);
        outputField("",  7, true);
        outputField(TWO_DIGITS_INTEGER,          dtc.getDate().getDay(),           9);
        outputField("", 10, true);
        outputField(TWO_DIGITS_INTEGER,          dtc.getTime().getHour(),         12);
        outputField("", 13, true);
        outputField(TWO_DIGITS_INTEGER,          dtc.getTime().getMinute(),       15);
        outputField(ELEVEN_SEVEN_DIGITS_FLOAT,   dtc.getTime().getSecond(),       26);

        // event flag
        outputField("", 28, true);
        if (first.getEventFlag() == 0) {
            outputField("", 29, true);
        } else {
            outputField(ONE_DIGIT_INTEGER, first.getEventFlag(), 29);
        }

        // list of satellites and receiver clock offset
        outputField(THREE_DIGITS_INTEGER, pending.size(), 32);
        boolean offsetWritten = false;
        final double  clockOffset   = first.getRcvrClkOffset();
        for (final ObservationDataSet ods : pending) {
            int next = column + 3;
            if (next > 68) {
                // we need to set up a continuation line
                if (clockOffset != 0.0) {
                    outputField(TWELVE_NINE_DIGITS_FLOAT, clockOffset, 80);
                }
                offsetWritten = true;
                finishLine();
                outputField("", 32, true);
                next = column + 3;
            }
            outputField(ods.getSatellite().getSystem().getKey(), next - 2);
            outputField(PADDED_TWO_DIGITS_INTEGER, ods.getSatellite().getTwoDigitsRinexPRN(), next);
        }
        if (!offsetWritten && clockOffset != 0.0) {
            outputField("", 68, true);
            outputField(TWELVE_NINE_DIGITS_FLOAT, first.getRcvrClkOffset(), 80);
        }
        finishLine();

        // observations per se
        for (final ObservationDataSet ods : pending) {
            for (final ObservationData od : ods.getObservationData()) {
                int next = column + 16;
                if (next > 80) {
                    // we need to set up a continuation line
                    finishLine();
                    next = column + 16;
                }
                final double scaling = getScaling(od.getObservationType(), ods.getSatellite().getSystem());
                outputField(FOURTEEN_THREE_DIGITS_FLOAT, scaling * od.getValue(), next - 2);
                if (od.getLossOfLockIndicator() == 0) {
                    outputField("", next - 1, true);
                } else {
                    outputField(ONE_DIGIT_INTEGER, od.getLossOfLockIndicator(), next - 1);
                }
                if (od.getSignalStrength() == 0) {
                    outputField("", next, true);
                } else {
                    outputField(ONE_DIGIT_INTEGER, od.getSignalStrength(), next);
                }
            }
            finishLine();
        }

    }

    /** Write one observation data set in RINEX 3/4 format.
     * @exception IOException if an I/O error occurs.
     */
    public void writePendingRinex34Observations()
        throws IOException {

        final ObservationDataSet first = pending.get(0);

        // EPOCH/SAT
        final DateTimeComponents dtc = first.getDate().getComponents(timeScale);
        outputField(">",  2, true);
        outputField(FOUR_DIGITS_INTEGER,         dtc.getDate().getYear(),    6);
        outputField("",   7, true);
        outputField(PADDED_TWO_DIGITS_INTEGER,   dtc.getDate().getMonth(),   9);
        outputField("",  10, true);
        outputField(PADDED_TWO_DIGITS_INTEGER,   dtc.getDate().getDay(),    12);
        outputField("", 13, true);
        outputField(PADDED_TWO_DIGITS_INTEGER,   dtc.getTime().getHour(),   15);
        outputField("", 16, true);
        outputField(PADDED_TWO_DIGITS_INTEGER,   dtc.getTime().getMinute(), 18);
        outputField(ELEVEN_SEVEN_DIGITS_FLOAT,   dtc.getTime().getSecond(), 29);

        // event flag
        outputField("", 31, true);
        if (first.getEventFlag() == 0) {
            outputField("", 32, true);
        } else {
            outputField(ONE_DIGIT_INTEGER, first.getEventFlag(), 32);
        }

        // number of satellites and receiver clock offset
        outputField(THREE_DIGITS_INTEGER, pending.size(), 35);
        if (first.getRcvrClkOffset() != 0.0) {
            outputField("", 41, true);
            outputField(FIFTEEN_TWELVE_DIGITS_FLOAT, first.getRcvrClkOffset(), 56);
        }
        finishLine();

        // observations per se
        for (final ObservationDataSet ods : pending) {
            outputField(ods.getSatellite().getSystem().getKey(), 1);
            outputField(PADDED_TWO_DIGITS_INTEGER, ods.getSatellite().getTwoDigitsRinexPRN(), 3);
            for (final ObservationData od : ods.getObservationData()) {
                final int next = column + 16;
                final double scaling = getScaling(od.getObservationType(), ods.getSatellite().getSystem());
                outputField(FOURTEEN_THREE_DIGITS_FLOAT, scaling * od.getValue(), next - 2);
                if (od.getLossOfLockIndicator() == 0) {
                    outputField("", next - 1, true);
                } else {
                    outputField(ONE_DIGIT_INTEGER, od.getLossOfLockIndicator(), next - 1);
                }
                if (od.getSignalStrength() == 0) {
                    outputField("", next, true);
                } else {
                    outputField(ONE_DIGIT_INTEGER, od.getSignalStrength(), next);
                }
            }
            finishLine();
        }

    }

    /** Write one header string.
     * @param s string data (may be null)
     * @param label line label
     * @throws IOException if an I/O error occurs.
     */
    private void writeHeaderLine(final String s, final RinexLabels label) throws IOException {
        if (s != null) {
            outputField(s, s.length(), true);
            finishHeaderLine(label);
        }
    }

    /** Write one header vector.
     * @param vector vector data (may be null)
     * @param label line label
     * @throws IOException if an I/O error occurs.
     */
    private void writeHeaderLine(final Vector3D vector, final RinexLabels label) throws IOException {
        if (vector != null) {
            outputField(FOURTEEN_FOUR_DIGITS_FLOAT, vector.getX(), 14);
            outputField(FOURTEEN_FOUR_DIGITS_FLOAT, vector.getY(), 28);
            outputField(FOURTEEN_FOUR_DIGITS_FLOAT, vector.getZ(), 42);
            finishHeaderLine(label);
        }
    }

    /** Finish one header line.
     * @param label line label
     * @throws IOException if an I/O error occurs.
     */
    private void finishHeaderLine(final RinexLabels label) throws IOException {
        for (int i = column; i < LABEL_INDEX; ++i) {
            output.append(' ');
        }
        output.append(label.getLabel());
        finishLine();
    }

    /** Finish one line.
     * @throws IOException if an I/O error occurs.
     */
    private void finishLine() throws IOException {

        // pending line
        output.append(System.lineSeparator());
        lineNumber++;
        column = 0;

        // emit comments that should be placed at next lines
        for (final RinexComment comment : savedComments) {
            if (comment.getLineNumber() == lineNumber) {
                outputField(comment.getText(), LABEL_INDEX, true);
                output.append(RinexLabels.COMMENT.getLabel());
                output.append(System.lineSeparator());
                lineNumber++;
                column = 0;
            } else if (comment.getLineNumber() > lineNumber) {
                break;
            }
        }

    }

    /** Output one single character field.
     * @param c field value
     * @param next target column for next field
     * @throws IOException if an I/O error occurs.
     */
    private void outputField(final char c, final int next) throws IOException {
        outputField(Character.toString(c), next, false);
    }

    /** Output one integer field.
     * @param format format to use
     * @param value field value
     * @param next target column for next field
     * @throws IOException if an I/O error occurs.
     */
    private void outputField(final String format, final int value, final int next) throws IOException {
        outputField(String.format(Locale.US, format, value), next, false);
    }

    /** Output one double field.
     * @param format format to use
     * @param value field value
     * @param next target column for next field
     * @throws IOException if an I/O error occurs.
     */
    private void outputField(final String format, final double value, final int next) throws IOException {
        if (Double.isNaN(value)) {
            // NaN values are replaced by blank fields
            outputField("", next, true);
        } else {
            outputField(String.format(Locale.US, format, value), next, false);
        }
    }

    /** Output one field.
     * @param field field to output
     * @param next target column for next field
     * @param leftJustified if true, field is left-justified
     * @throws IOException if an I/O error occurs.
     */
    private void outputField(final String field, final int next, final boolean leftJustified) throws IOException {
        final int padding = next - (field == null ? 0 : field.length()) - column;
        if (leftJustified && field != null) {
            output.append(field);
        }
        for (int i = 0; i < padding; ++i) {
            output.append(' ');
        }
        if (!leftJustified && field != null) {
            output.append(field);
        }
        column = next;
    }

    /** Get the scaling factor for an observation.
     * @param type type of observation
     * @param system satellite system for the observation
     * @return scaling factor
     */
    private double getScaling(final ObservationType type, final SatelliteSystem system) {

        for (final ScaleFactorCorrection scaleFactorCorrection : savedHeader.getScaleFactorCorrections(system)) {
            // check if the next Observation Type to read needs to be scaled
            if (scaleFactorCorrection.getTypesObsScaled().contains(type)) {
                return scaleFactorCorrection.getCorrection();
            }
        }

        // no scaling
        return 1.0;

    }

}