StreamingAemWriter.java

/* Copyright 2002-2020 CS GROUP
 * 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.ccsds;

import java.io.IOException;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;

import org.hipparchus.exception.LocalizedCoreFormats;
import org.hipparchus.geometry.euclidean.threed.RotationOrder;
import org.orekit.errors.OrekitException;
import org.orekit.files.ccsds.AEMParser.AEMRotationOrder;
import org.orekit.propagation.SpacecraftState;
import org.orekit.propagation.sampling.OrekitFixedStepHandler;
import org.orekit.time.AbsoluteDate;
import org.orekit.time.DateTimeComponents;
import org.orekit.time.TimeComponents;
import org.orekit.time.TimeScale;
import org.orekit.utils.TimeStampedAngularCoordinates;

/**
 * A writer for AEM files.
 *
 * <p> Each instance corresponds to a single AEM file.
 *
 * <h2> Metadata </h2>
 *
 * <p> The AEM metadata used by this writer is described in the following table. Many
 * metadata items are optional or have default values so they do not need to be specified.
 * At a minimum the user must supply those values that are required and for which no
 * default exits: {@link Keyword#OBJECT_NAME}, and {@link Keyword#OBJECT_ID}. The usage
 * column in the table indicates where the metadata item is used, either in the AEM header
 * or in the metadata section at the start of an AEM attitude segment.
 *
 * <p> The AEM metadata for the whole AEM file is set in the {@link
 * #StreamingAemWriter(Appendable, TimeScale, Map) constructor}.
 *
 * <table>
 * <caption>AEM metadata</caption>
 *     <thead>
 *         <tr>
 *             <th>Keyword
 *             <th>Usage
 *             <th>Obligatory
 *             <th>Default
 *             <th>Reference
 *    </thead>
 *    <tbody>
 *        <tr>
 *            <td>{@link Keyword#CCSDS_AEM_VERS}
 *            <td>Header
 *            <td>Yes
 *            <td>{@link #CCSDS_AEM_VERS}
 *            <td>Table 4-2
 *        <tr>
 *            <td>{@link Keyword#COMMENT}
 *            <td>Header
 *            <td>No
 *            <td>
 *            <td>Table 4-2
 *        <tr>
 *            <td>{@link Keyword#CREATION_DATE}
 *            <td>Header
 *            <td>Yes
 *            <td>{@link Date#Date() Now}
 *            <td>Table 4-2
 *        <tr>
 *            <td>{@link Keyword#ORIGINATOR}
 *            <td>Header
 *            <td>Yes
 *            <td>{@link #DEFAULT_ORIGINATOR}
 *            <td>Table 4-2
 *        <tr>
 *            <td>{@link Keyword#OBJECT_NAME}
 *            <td>Segment
 *            <td>Yes
 *            <td>
 *            <td>Table 4-3
 *        <tr>
 *            <td>{@link Keyword#OBJECT_ID}
 *            <td>Segment
 *            <td>Yes
 *            <td>
 *            <td>Table 4-3
 *        <tr>
 *            <td>{@link Keyword#CENTER_NAME}
 *            <td>Segment
 *            <td>No
 *            <td>
 *            <td>Table 4-3
 *        <tr>
 *            <td>{@link Keyword#REF_FRAME_A}
 *            <td>Segment
 *            <td>Yes
 *            <td>
 *            <td>Table 4-3
 *        <tr>
 *            <td>{@link Keyword#REF_FRAME_B}
 *            <td>Segment
 *            <td>Yes
 *            <td>
 *            <td>Table 4-3
 *        <tr>
 *            <td>{@link Keyword#ATTITUDE_DIR}
 *            <td>Segment
 *            <td>Yes
 *            <td>
 *            <td>Table 4-3
 *        <tr>
 *            <td>{@link Keyword#TIME_SYSTEM}
 *            <td>Segment
 *            <td>Yes
 *            <td>
 *            <td>Table 4-3, Annex A
 *        <tr>
 *            <td>{@link Keyword#START_TIME}
 *            <td>Segment
 *            <td>Yes
 *            <td>
 *            <td>Table 4-3
 *        <tr>
 *            <td>{@link Keyword#USEABLE_START_TIME}
 *            <td>Segment
 *            <td>No
 *            <td>
 *            <td>Table 4-3
 *        <tr>
 *            <td>{@link Keyword#STOP_TIME}
 *            <td>Segment
 *            <td>Yes
 *            <td>
 *            <td>Table 4-3
 *        <tr>
 *            <td>{@link Keyword#USEABLE_STOP_TIME}
 *            <td>Segment
 *            <td>No
 *            <td>
 *            <td>Table 4-3
 *        <tr>
 *            <td>{@link Keyword#ATTITUDE_TYPE}
 *            <td>Segment
 *            <td>Yes
 *            <td>
 *            <td>Table 4-3, 4-4
 *        <tr>
 *            <td>{@link Keyword#QUATERNION_TYPE}
 *            <td>Segment
 *            <td>No
 *            <td>
 *            <td>Table 4-3, 4-4
 *        <tr>
 *            <td>{@link Keyword#EULER_ROT_SEQ}
 *            <td>Segment
 *            <td>No
 *            <td>
 *            <td>Table 4-3
 *        <tr>
 *            <td>{@link Keyword#RATE_FRAME}
 *            <td>Segment
 *            <td>No
 *            <td>
 *            <td>Table 4-3
 *        <tr>
 *            <td>{@link Keyword#INTERPOLATION_METHOD}
 *            <td>Segment
 *            <td>No
 *            <td>
 *            <td>Table 4-3
 *        <tr>
 *            <td>{@link Keyword#INTERPOLATION_DEGREE}
 *            <td>Segment
 *            <td>No
 *            <td>
 *            <td>Table 4-3
 *    </tbody>
 *</table>
 *
 * <p> The {@link Keyword#TIME_SYSTEM} must be constant for the whole file and is used
 * to interpret all dates except {@link Keyword#CREATION_DATE}. The guessing algorithm
 * is not guaranteed to work so it is recommended to provide values for {@link
 * Keyword#CENTER_NAME} and {@link Keyword#TIME_SYSTEM} to avoid any bugs associated with
 * incorrect guesses.
 *
 * <p> Standardized values for {@link Keyword#TIME_SYSTEM} are GMST, GPS, MET, MRT, SCLK,
 * TAI, TCB, TDB, TT, UT1, and UTC. Standardized values for reference frames
 * are EME2000, GTOD, ICRF, ITRF2000, ITRF-93, ITRF-97, LVLH, RTN, QSW, TOD, TNW, NTW and RSW.
 * Additionally ITRF followed by a four digit year may be used.
 *
 * @author Bryan Cazabonne
 * @see <a href="https://public.ccsds.org/Pubs/504x0b1c1.pdf">CCSDS 504.0-B-1 Attitude Data Messages</a>
 * @see AEMWriter
 * @since 10.2
 */
public class StreamingAemWriter {

    /** Version number implemented. **/
    public static final String CCSDS_AEM_VERS = "1.0";

    /** Default value for {@link Keyword#ORIGINATOR}. */
    public static final String DEFAULT_ORIGINATOR = "OREKIT";

    /**
     * Default format used for attitude ephemeris data output: 5 digits
     * after the decimal point and leading space for positive values.
     */
    public static final String DEFAULT_ATTITUDE_FORMAT = "% .5f";

    /** New line separator for output file. See 5.4.5. */
    private static final String NEW_LINE = "\n";

    /**
     * Standardized locale to use, to ensure files can be exchanged without
     * internationalization issues.
     */
    private static final Locale STANDARDIZED_LOCALE = Locale.US;

    /** String format used for all key/value pair lines. **/
    private static final String KV_FORMAT = "%s = %s%n";

    /** Output stream. */
    private final Appendable writer;

    /** Metadata for this AEM file. */
    private final Map<Keyword, String> metadata;

    /** Time scale for all dates except {@link Keyword#CREATION_DATE}. */
    private final TimeScale timeScale;

    /** Format for attitude ephemeris data output. */
    private final String attitudeFormat;

    /**
     * Create an AEM writer that streams data to the given output stream.
     * {@link #DEFAULT_ATTITUDE_FORMAT Default formatting} will be used for attitude ephemeris data.
     *
     * @param writer    The output stream for the AEM file. Most methods will append data
     *                  to this {@code writer}.
     * @param timeScale for all times in the AEM except {@link Keyword#CREATION_DATE}. See
     *                  Section 4.2.5.4.2 and Annex A.
     * @param metadata  for the satellite.
     */
    public StreamingAemWriter(final Appendable writer,
                              final TimeScale timeScale,
                              final Map<Keyword, String> metadata) {
        this(writer, timeScale, metadata, DEFAULT_ATTITUDE_FORMAT);
    }

    /**
     * Create an AEM writer than streams data to the given output stream as
     * {@link #StreamingAemWriter(Appendable, TimeScale, Map)} with
     * {@link java.util.Formatter format parameters} for attitude ephemeris data.
     *
     * @param writer    The output stream for the AEM file. Most methods will append data
     *                  to this {@code writer}.
     * @param timeScale for all times in the AEM except {@link Keyword#CREATION_DATE}. See
     *                  Section 4.2.5.4.2 and Annex A.
     * @param metadata  for the satellite.
     * @param attitudeFormat format parameters for attitude ephemeris data output.
     * @since 10.3
     */
    public StreamingAemWriter(final Appendable writer,
                              final TimeScale timeScale,
                              final Map<Keyword, String> metadata,
                              final String attitudeFormat) {
        this.writer    = writer;
        this.timeScale = timeScale;
        this.metadata  = new LinkedHashMap<>(metadata);

        // Set default metadata
        this.metadata.putIfAbsent(Keyword.CCSDS_AEM_VERS, CCSDS_AEM_VERS);

        // creation date is informational only
        this.metadata.putIfAbsent(Keyword.CREATION_DATE,
                ZonedDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT));
        this.metadata.putIfAbsent(Keyword.ORIGINATOR, DEFAULT_ORIGINATOR);
        this.metadata.putIfAbsent(Keyword.TIME_SYSTEM, timeScale.getName());
        this.attitudeFormat = attitudeFormat;
    }

    /**
     * Write a single key and value to the stream using Key Value Notation (KVN).
     * @param key   the keyword to write
     * @param value the value to write
     * @throws IOException if an I/O error occurs.
     */
    private void writeKeyValue(final Keyword key, final String value) throws IOException {
        writer.append(String.format(STANDARDIZED_LOCALE, KV_FORMAT, key.toString(), value));
    }

    /**
     * Writes the standard AEM header for the file.
     * @throws IOException if the stream cannot write to stream
     */
    public void writeHeader() throws IOException {
        writeKeyValue(Keyword.CCSDS_AEM_VERS, this.metadata.get(Keyword.CCSDS_AEM_VERS));
        final String comment = this.metadata.get(Keyword.COMMENT);
        if (comment != null) {
            writeKeyValue(Keyword.COMMENT, comment);
        }
        writeKeyValue(Keyword.CREATION_DATE, this.metadata.get(Keyword.CREATION_DATE));
        writeKeyValue(Keyword.ORIGINATOR, this.metadata.get(Keyword.ORIGINATOR));
        writer.append(NEW_LINE);
    }

    /**
     * Create a writer for a new AEM attitude ephemeris segment.
     * <p> The returned writer can only write a single attitude ephemeris segment in an AEM.
     * This method must be called to create a writer for each attitude ephemeris segment.
     * @param segmentMetadata the metadata to use for the segment. Overrides for this
     *                        segment any other source of meta data values. See {@link
     *                        #StreamingAemWriter} for a description of which metadata are
     *                        required and how they are determined.
     * @return a new AEM segment, ready for writing.
     */
    public AEMSegment newSegment(final Map<Keyword, String> segmentMetadata) {
        final Map<Keyword, String> meta = new LinkedHashMap<>(this.metadata);
        meta.putAll(segmentMetadata);
        return new AEMSegment(meta);
    }

    /**
     * Convert a date to a string with more precision.
     *
     * @param components to convert to a String.
     * @return the String form of {@code date} with at least 9 digits of precision.
     */
    static String dateToString(final DateTimeComponents components) {
        final TimeComponents time = components.getTime();
        final int hour = time.getHour();
        final int minute = time.getMinute();
        final double second = time.getSecond();
        // Decimal formatting classes could be static final if they were thread safe.
        final DecimalFormatSymbols locale = new DecimalFormatSymbols(STANDARDIZED_LOCALE);
        final DecimalFormat twoDigits = new DecimalFormat("00", locale);
        final DecimalFormat precise = new DecimalFormat("00.0########", locale);
        return components.getDate().toString() + "T" + twoDigits.format(hour) + ":" +
                twoDigits.format(minute) + ":" + precise.format(second);
    }

    /** A writer for a segment of an AEM. */
    public class AEMSegment implements OrekitFixedStepHandler {

        /** Metadata for this AEM Segment. */
        private final Map<Keyword, String> metadata;

        /**
         * Create a new segment writer.
         * @param metadata to use when writing this segment.
         */
        private AEMSegment(final Map<Keyword, String> metadata) {
            this.metadata = metadata;
        }

        /**
         * Write the ephemeris segment metadata.
         *
         * <p> See {@link StreamingAemWriter} for a description of how the metadata is
         * set.
         *
         * @throws IOException if the output stream throws one while writing.
         */
        public void writeMetadata() throws IOException {
            // Start metadata
            writer.append("META_START").append(NEW_LINE);

            // Table 4.3
            writeKeyValue(Keyword.OBJECT_NAME,  this.metadata.get(Keyword.OBJECT_NAME));
            writeKeyValue(Keyword.OBJECT_ID,    this.metadata.get(Keyword.OBJECT_ID));
            writeKeyValue(Keyword.CENTER_NAME,  this.metadata.get(Keyword.CENTER_NAME));
            writeKeyValue(Keyword.REF_FRAME_A,  this.metadata.get(Keyword.REF_FRAME_A));
            writeKeyValue(Keyword.REF_FRAME_B,  this.metadata.get(Keyword.REF_FRAME_B));
            writeKeyValue(Keyword.ATTITUDE_DIR, this.metadata.get(Keyword.ATTITUDE_DIR));
            writeKeyValue(Keyword.TIME_SYSTEM,  this.metadata.get(Keyword.TIME_SYSTEM));
            writeKeyValue(Keyword.START_TIME,   this.metadata.get(Keyword.START_TIME));

            // Optional values: USEABLE_START_TIME & USEABLE_STOP_TIME
            final String usableStartTime = this.metadata.get(Keyword.USEABLE_START_TIME);
            if (usableStartTime != null) {
                writeKeyValue(Keyword.USEABLE_START_TIME, usableStartTime);
            }
            final String usableStopTime = this.metadata.get(Keyword.USEABLE_STOP_TIME);
            if (usableStopTime != null) {
                writeKeyValue(Keyword.USEABLE_STOP_TIME, usableStopTime);
            }

            // Table 4.3
            writeKeyValue(Keyword.STOP_TIME,     this.metadata.get(Keyword.STOP_TIME));
            writeKeyValue(Keyword.ATTITUDE_TYPE, this.metadata.get(Keyword.ATTITUDE_TYPE));

            // Optional values: QUATERNION_ TYPE; EULER_ROT_SEQ; RATE_FRAME; INTERPOLATION_METHOD and INTERPOLATION_DEGREE
            final String quaternionType = this.metadata.get(Keyword.QUATERNION_TYPE);
            if (quaternionType != null) {
                writeKeyValue(Keyword.QUATERNION_TYPE, quaternionType);
            }
            final String eulerRotSeq = this.metadata.get(Keyword.EULER_ROT_SEQ);
            if (eulerRotSeq != null) {
                writeKeyValue(Keyword.EULER_ROT_SEQ, eulerRotSeq);
            }
            final String rateFrame = this.metadata.get(Keyword.RATE_FRAME);
            if (rateFrame != null) {
                writeKeyValue(Keyword.RATE_FRAME, rateFrame);
            }
            final String interpolationMethod = this.metadata.get(Keyword.INTERPOLATION_METHOD);
            if (interpolationMethod != null) {
                writeKeyValue(Keyword.INTERPOLATION_METHOD, interpolationMethod);
            }
            final String interpolationDegree = this.metadata.get(Keyword.INTERPOLATION_DEGREE);
            if (interpolationDegree != null) {
                writeKeyValue(Keyword.INTERPOLATION_DEGREE, interpolationDegree);
            }

            // Stop metadata
            writer.append("META_STOP").append(NEW_LINE).append(NEW_LINE);
        }

        /**
         * Write a single attitude ephemeris line according to section 4.2.4 and Table 4-4.
         * @param attitude the attitude information for a given date.
         * @param isFirst true if QC is the first element in the attitude data
         * @param attitudeName name of the attitude type
         * @param rotationOrder rotation order
         * @throws IOException if the output stream throws one while writing.
         */
        public void writeAttitudeEphemerisLine(final TimeStampedAngularCoordinates attitude,
                                               final boolean isFirst,
                                               final String attitudeName,
                                               final RotationOrder rotationOrder)
            throws IOException {
            // Epoch
            final String epoch = dateToString(attitude.getDate().getComponents(timeScale));
            writer.append(epoch).append(" ");
            // Attitude data in degrees
            final AEMAttitudeType type = AEMAttitudeType.getAttitudeType(attitudeName);
            final double[]        data = type.getAttitudeData(attitude, isFirst, rotationOrder);
            final int             size = data.length;
            for (int index = 0; index < size; index++) {
                writer.append(String.format(STANDARDIZED_LOCALE, attitudeFormat, data[index]));
                final String space = (index == size - 1) ? "" : " ";
                writer.append(space);
            }
            // end the line
            writer.append(NEW_LINE);
        }

        /**
         * {@inheritDoc}
         *
         * <p> Sets the {@link Keyword#START_TIME} and {@link Keyword#STOP_TIME} in this
         * segment's metadata if not already set by the user. Then calls {@link
         * #writeMetadata()} to start the segment.
         */
        @Override
        public void init(final SpacecraftState s0,
                         final AbsoluteDate t,
                         final double step) {
            try {
                final String start = dateToString(s0.getDate().getComponents(timeScale));
                final String stop = dateToString(t.getComponents(timeScale));
                this.metadata.putIfAbsent(Keyword.START_TIME, start);
                this.metadata.putIfAbsent(Keyword.STOP_TIME, stop);
                this.writeMetadata();
            } catch (IOException e) {
                throw new OrekitException(e, LocalizedCoreFormats.SIMPLE_MESSAGE,
                        e.getLocalizedMessage());
            }
        }

        /** {@inheritDoc}. */
        @Override
        public void handleStep(final SpacecraftState currentState, final boolean isLast) {
            try {

                // Quaternion type
                final String quaternionType = this.metadata.get(Keyword.QUATERNION_TYPE);
                // If the QUATERNION_TYPE keyword is not present in the file, this means that
                // the attitude data are not given using quaternion. Therefore, the computation
                // of the attitude data will not be sensitive to this parameter. A default value
                // can be set
                boolean isFirst = false;
                if (quaternionType != null) {
                    isFirst = (quaternionType.equals("FIRST")) ? true : false;
                }

                // Attitude type
                final String attitudeType = this.metadata.get(Keyword.ATTITUDE_TYPE);

                // Rotation order
                final String eulerRotSeq = this.metadata.get(Keyword.EULER_ROT_SEQ);
                final RotationOrder order = (eulerRotSeq == null) ? null : AEMRotationOrder.getRotationOrder(eulerRotSeq);

                // Write attitude ephemeris data
                writeAttitudeEphemerisLine(currentState.getAttitude().getOrientation(), isFirst,
                                           attitudeType, order);

            } catch (IOException e) {
                throw new OrekitException(e, LocalizedCoreFormats.SIMPLE_MESSAGE,
                        e.getLocalizedMessage());
            }

        }

        /**
         * Start of an attitude block.
         * @throws IOException if the output stream throws one while writing.
         */
        void startAttitudeBlock() throws IOException {
            writer.append("DATA_START").append(NEW_LINE);
        }

        /**
         * End of an attitude block.
         * @throws IOException if the output stream throws one while writing.
         */
        void endAttitudeBlock() throws IOException {
            writer.append("DATA_STOP").append(NEW_LINE);
        }

    }

}