StreamingOemWriter.java
/* Contributed in the public domain.
* 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.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.hipparchus.exception.LocalizedCoreFormats;
import org.hipparchus.linear.RealMatrix;
import org.orekit.bodies.CelestialBodyFactory;
import org.orekit.errors.OrekitException;
import org.orekit.errors.OrekitMessages;
import org.orekit.files.ccsds.OEMFile.CovarianceMatrix;
import org.orekit.frames.FactoryManagedFrame;
import org.orekit.frames.Frame;
import org.orekit.frames.LOFType;
import org.orekit.frames.Predefined;
import org.orekit.frames.VersionedITRF;
import org.orekit.propagation.Propagator;
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.TimeStampedPVCoordinates;
/**
* A writer for OEM files.
*
* <p> Each instance corresponds to a single OEM file. A new OEM ephemeris segment is
* started by calling {@link #newSegment(Frame, Map)}.
*
* <h3> Metadata </h3>
*
* <p> The OEM 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 OEM header
* or in the metadata section at the start of an OEM ephemeris segment.
*
* <p> The OEM metadata for the whole OEM file is set in the {@link
* #StreamingOemWriter(Appendable, TimeScale, Map) constructor}. Any of the metadata may
* be overridden for a particular segment using the {@code metadata} argument to {@link
* #newSegment(Frame, Map)}.
*
* <table summary="OEM metada">
* <thead>
* <tr>
* <th>Keyword
* <th>Usage
* <th>Obligatory
* <th>Default
* <th>Reference
* </thead>
* <tbody>
* <tr>
* <td>{@link Keyword#CCSDS_OEM_VERS}
* <td>Header
* <td>Yes
* <td>{@link #CCSDS_OEM_VERS}
* <td>Table 5-2
* <tr>
* <td>{@link Keyword#COMMENT}
* <td>Header
* <td>No
* <td>
* <td>Table 5-2
* <tr>
* <td>{@link Keyword#CREATION_DATE}
* <td>Header
* <td>Yes
* <td>{@link Date#Date() Now}
* <td>Table 5.2, 6.5.9
* <tr>
* <td>{@link Keyword#ORIGINATOR}
* <td>Header
* <td>Yes
* <td>{@link #DEFAULT_ORIGINATOR}
* <td>Table 5-2
* <tr>
* <td>{@link Keyword#OBJECT_NAME}
* <td>Segment
* <td>Yes
* <td>
* <td>Table 5-3
* <tr>
* <td>{@link Keyword#OBJECT_ID}
* <td>Segment
* <td>Yes
* <td>
* <td>Table 5-3
* <tr>
* <td>{@link Keyword#CENTER_NAME}
* <td>Segment
* <td>Yes
* <td>Guessed from the {@link #newSegment(Frame, Map) segment}'s {@code frame}
* <td>Table 5-3
* <tr>
* <td>{@link Keyword#REF_FRAME}
* <td>Segment
* <td>Yes
* <td>Guessed from the {@link #newSegment(Frame, Map) segment}'s {@code frame}
* <td>Table 5-3, Annex A
* <tr>
* <td>{@link Keyword#REF_FRAME_EPOCH}
* <td>Segment
* <td>No
* <td>
* <td>Table 5-3, 6.5.9
* <tr>
* <td>{@link Keyword#TIME_SYSTEM}
* <td>Segment
* <td>Yes
* <td>Guessed from {@code timeScale} set in the
* {@link #StreamingOemWriter(Appendable, TimeScale, Map) constructor}.
* <td>Table 5-3, Annex A
* <tr>
* <td>{@link Keyword#START_TIME}
* <td>Segment
* <td>Yes
* <td>Date of initial state in {@link Segment#init(SpacecraftState,
* AbsoluteDate, double) Segment.init(...)}
* <td>Table 5-3, 6.5.9
* <tr>
* <td>{@link Keyword#USEABLE_START_TIME}
* <td>Segment
* <td>No
* <td>
* <td>Table 5-3, 6.5.9
* <tr>
* <td>{@link Keyword#STOP_TIME}
* <td>Segment
* <td>Yes
* <td>Target date in {@link Segment#init(SpacecraftState,
* AbsoluteDate, double) Segment.init(...)}
* <td>Table 5-3, 6.5.9
* <tr>
* <td>{@link Keyword#USEABLE_STOP_TIME}
* <td>Segment
* <td>No
* <td>
* <td>Table 5-3, 6.5.9
* <tr>
* <td>{@link Keyword#INTERPOLATION}
* <td>Segment
* <td>No
* <td>
* <td>Table 5-3
* <tr>
* <td>{@link Keyword#INTERPOLATION_DEGREE}
* <td>Segment
* <td>No
* <td>
* <td>Table 5-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}, {@link Keyword#REF_FRAME}, and {@link Keyword#TIME_SYSTEM} to
* avoid any bugs associated with incorrect guesses.
*
* <p> Standardized values for {@link Keyword#TIME_SYSTEM} are GMST, GPS, ME, MRT, SCLK,
* TAI, TCB, TDB, TCG, TT, UT1, and UTC. Standardized values for {@link Keyword#REF_FRAME}
* are EME2000, GCRF, GRC, ICRF, ITRF2000, ITRF-93, ITRF-97, MCI, TDR, TEME, and TOD.
* Additionally ITRF followed by a four digit year may be used.
*
* <h3> Examples </h3>
*
* <p> This class can be used as a step handler for a {@link Propagator}, or on its own.
* Either way the object name and ID must be specified. The following example shows its
* use as a step handler.
*
* <pre>{@code
* Propagator propagator = ...; // pre-configured propagator
* Appendable out = ...; // set-up output stream
* Map<Keyword, String> metadata = new LinkedHashMap<>();
* metadata.put(Keyword.OBJECT_NAME, "Vanguard");
* metadata.put(Keyword.OBJECT_ID, "1958-002B");
* StreamingOemWriter writer = new StreamingOemWriter(out, utc, metadata);
* writer.writeHeader();
* Segment segment = writer.newSegment(frame, Collections.emptyMap());
* propagator.setMasterMode(step, segment);
* propagator.propagate(startDate, stopDate);
* }</pre>
*
* Alternatively a collection of state vectors can be written without the use of a
* Propagator. In this case the {@link Keyword#START_TIME} and {@link Keyword#STOP_TIME}
* need to be specified as part of the metadata.
*
* <pre>{@code
* List<TimeStampedPVCoordinates> states = ...; // pre-generated states
* Appendable out = ...; // set-up output stream
* Map<Keyword, String> metadata = new LinkedHashMap<>();
* metadata.put(Keyword.OBJECT_NAME, "Vanguard");
* metadata.put(Keyword.OBJECT_ID, "1958-002B");
* StreamingOemWriter writer = new StreamingOemWriter(out, utc, metadata);
* writer.writeHeader();
* // manually set start and stop times for this segment
* Map<Keyword, String> segmentData = new LinkedHashMap<>();
* segmentData.put(Keyword.START_TIME, start.toString());
* segmentData.put(Keyword.STOP_TIME, stop.toString());
* Segment segment = writer.newSegment(frame, segmentData);
* segment.writeMetadata(); // output metadata block
* for (TimeStampedPVCoordinates state : states) {
* segment.writeEphemerisLine(state);
* }
* }</pre>
*
* @author Evan Ward
* @see <a href="https://public.ccsds.org/Pubs/502x0b2c1.pdf">CCSDS 502.0-B-2 Orbit Data
* Messages</a>
* @see <a href="https://public.ccsds.org/Pubs/500x0g3.pdf">CCSDS 500.0-G-3 Navigation
* Data Definitions and Conventions</a>
* @see OEMWriter
*/
public class StreamingOemWriter {
/** Version number implemented. **/
public static final String CCSDS_OEM_VERS = "2.0";
/** Default value for {@link Keyword#ORIGINATOR}. */
public static final String DEFAULT_ORIGINATOR = "OREKIT";
/** New line separator for output file. See 6.3.6. */
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";
/** Factor for converting meters to km. */
private static final double M_TO_KM = 1e-3;
/** Suffix of the name of the inertial frame attached to a planet. */
private static final String INERTIAL_FRAME_SUFFIX = "/inertial";
/** Output stream. */
private final Appendable writer;
/** Metadata for this OEM file. */
private final Map<Keyword, String> metadata;
/** Time scale for all dates except {@link Keyword#CREATION_DATE}. */
private final TimeScale timeScale;
/**
* Create an OEM writer than streams data to the given output stream.
*
* @param writer The output stream for the OEM file. Most methods will append data
* to this {@code writer}.
* @param timeScale for all times in the OEM except {@link Keyword#CREATION_DATE}. See
* Section 5.2.4.5 and Annex A.
* @param metadata for the satellite. Can be overridden in {@link #newSegment(Frame,
* Map)} for a specific segment. See {@link StreamingOemWriter}.
*/
public StreamingOemWriter(final Appendable writer,
final TimeScale timeScale,
final Map<Keyword, String> metadata) {
this.writer = writer;
this.timeScale = timeScale;
this.metadata = new LinkedHashMap<>(metadata);
// set default metadata
this.metadata.putIfAbsent(Keyword.CCSDS_OEM_VERS, CCSDS_OEM_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());
}
/**
* Guesses names from Table 5-3 and Annex A.
*
* <p> The goal of this method is to perform the opposite mapping of {@link
* CCSDSFrame}.
*
* @param frame a reference frame for ephemeris output.
* @return the string to use in the OEM file to identify {@code frame}.
*/
static String guessFrame(final Frame frame) {
// define some constant strings to make checkstyle happy
final String tod = "TOD";
final String itrf = "ITRF";
// Try to determine the CCSDS name from Annex A by examining the Orekit name.
final String name = frame.getName();
if (Arrays.stream(CCSDSFrame.values())
.map(CCSDSFrame::name)
.anyMatch(name::equals)) {
// should handle J2000, GCRF, TEME, and some frames created by OEMParser.
return name;
} else if (frame instanceof CcsdsModifiedFrame) {
return ((CcsdsModifiedFrame) frame).getRefFrame();
} else if ((CelestialBodyFactory.MARS + INERTIAL_FRAME_SUFFIX).equals(name)) {
return "MCI";
} else if ((CelestialBodyFactory.SOLAR_SYSTEM_BARYCENTER + INERTIAL_FRAME_SUFFIX)
.equals(name)) {
return "ICRF";
} else if (name.contains("GTOD")) {
return "TDR";
} else if (name.contains(tod)) { // check after GTOD
return tod;
} else if (name.contains("Equinox") && name.contains(itrf)) {
return "GRC";
} else if (frame instanceof VersionedITRF) {
return ((VersionedITRF) frame).getITRFVersion().getName().replace("-", "");
} else if (name.contains("CIO") && name.contains(itrf)) {
return "ITRF2014";
} else {
// don't know how to map it to a CCSDS reference frame
return name;
}
}
/**
* Guess the name of the center of the reference frame.
*
* @param frame a reference frame for ephemeris output.
* @return the string to use in the OEM file to describe the origin of {@code frame}.
*/
static String guessCenter(final Frame frame) {
final String name = frame.getName();
if (name.endsWith(INERTIAL_FRAME_SUFFIX) || name.endsWith("/rotating")) {
return name.substring(0, name.length() - 9).toUpperCase(STANDARDIZED_LOCALE);
} else if (frame instanceof CcsdsModifiedFrame) {
return ((CcsdsModifiedFrame) frame).getCenterName();
} else if (frame.getName().equals(Predefined.ICRF.getName())) {
return CelestialBodyFactory.SOLAR_SYSTEM_BARYCENTER.toUpperCase(STANDARDIZED_LOCALE);
} else if (frame.getDepth() == 0 || frame instanceof FactoryManagedFrame) {
return "EARTH";
} else {
return "UNKNOWN";
}
}
/**
* 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 OEM header for the file.
*
* @throws IOException if the stream cannot write to stream
*/
public void writeHeader() throws IOException {
writeKeyValue(Keyword.CCSDS_OEM_VERS, this.metadata.get(Keyword.CCSDS_OEM_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 OEM ephemeris segment.
*
* <p> The returned writer can only write a single ephemeris segment in an OEM. This
* method must be called to create a writer for each ephemeris segment.
*
* @param frame the reference frame to use for the segment. If this value is
* {@code null} then {@link Segment#handleStep(SpacecraftState,
* boolean)} will throw a {@link NullPointerException} and the
* metadata item {@link Keyword#REF_FRAME} must be specified in
* the metadata.
* @param segmentMetadata the metadata to use for the segment. Overrides for this
* segment any other source of meta data values. See {@link
* #StreamingOemWriter} for a description of which metadata are
* required and how they are determined.
* @return a new OEM segment, ready for writing.
*/
public Segment newSegment(final Frame frame,
final Map<Keyword, String> segmentMetadata) {
final Map<Keyword, String> meta = new LinkedHashMap<>(this.metadata);
meta.putAll(segmentMetadata);
if (!meta.containsKey(Keyword.REF_FRAME)) {
meta.put(Keyword.REF_FRAME, guessFrame(frame));
}
if (!meta.containsKey(Keyword.CENTER_NAME)) {
meta.put(Keyword.CENTER_NAME, guessCenter(frame));
}
return new Segment(frame, meta);
}
/** A writer for a segment of an OEM. */
public class Segment implements OrekitFixedStepHandler {
/** Reference frame of the output states. */
private final Frame frame;
/** Metadata for this OEM Segment. */
private final Map<Keyword, String> metadata;
/**
* Create a new segment writer.
*
* @param frame for the output states. Used by {@link #handleStep(SpacecraftState,
* boolean)}.
* @param metadata to use when writing this segment.
*/
private Segment(final Frame frame, final Map<Keyword, String> metadata) {
this.frame = frame;
this.metadata = metadata;
}
/**
* Write the ephemeris segment metadata.
*
* <p> See {@link StreamingOemWriter} for a description of how the metadata is
* set.
*
* @throws IOException if the output stream throws one while writing.
*/
public void writeMetadata() throws IOException {
writer.append("META_START").append(NEW_LINE);
if (this.frame != null) {
writer.append("COMMENT ").append("Orekit frame: ")
.append(this.frame.toString()).append(NEW_LINE);
}
// Table 5.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, this.metadata.get(Keyword.REF_FRAME));
final String refFrameEpoch = this.metadata.get(Keyword.REF_FRAME_EPOCH);
if (refFrameEpoch != null) {
writeKeyValue(Keyword.REF_FRAME_EPOCH, refFrameEpoch);
}
writeKeyValue(Keyword.TIME_SYSTEM, this.metadata.get(Keyword.TIME_SYSTEM));
writeKeyValue(Keyword.START_TIME, this.metadata.get(Keyword.START_TIME));
final String usableStartTime = this.metadata.get(Keyword.USEABLE_START_TIME);
if (usableStartTime != null) {
writeKeyValue(Keyword.USEABLE_START_TIME, usableStartTime);
}
writeKeyValue(Keyword.STOP_TIME, this.metadata.get(Keyword.STOP_TIME));
final String usableStopTime = this.metadata.get(Keyword.USEABLE_STOP_TIME);
if (usableStopTime != null) {
writeKeyValue(Keyword.USEABLE_STOP_TIME, usableStopTime);
}
final String interpolation = this.metadata.get(Keyword.INTERPOLATION);
if (interpolation != null) {
writeKeyValue(Keyword.INTERPOLATION, interpolation);
}
final String interpolationDegree =
this.metadata.get(Keyword.INTERPOLATION_DEGREE);
if (interpolationDegree != null) {
writeKeyValue(Keyword.INTERPOLATION_DEGREE, interpolationDegree);
}
writer.append("META_STOP").append(NEW_LINE).append(NEW_LINE);
}
/**
* Write a single ephemeris line according to section 5.2.4. This method does not
* write the optional acceleration terms.
*
* @param pv the time, position, and velocity to write.
* @throws IOException if the output stream throws one while writing.
*/
public void writeEphemerisLine(final TimeStampedPVCoordinates pv)
throws IOException {
final String epoch = dateToString(pv.getDate().getComponents(timeScale));
writer.append(epoch).append(" ");
// output in km, see Section 6.6.2.1
writer.append(Double.toString(pv.getPosition().getX() * M_TO_KM)).append(" ");
writer.append(Double.toString(pv.getPosition().getY() * M_TO_KM)).append(" ");
writer.append(Double.toString(pv.getPosition().getZ() * M_TO_KM)).append(" ");
writer.append(Double.toString(pv.getVelocity().getX() * M_TO_KM)).append(" ");
writer.append(Double.toString(pv.getVelocity().getY() * M_TO_KM)).append(" ");
writer.append(Double.toString(pv.getVelocity().getZ() * M_TO_KM));
writer.append(NEW_LINE);
}
/**
* Write covariance matrices of the segment according to section 5.2.5.
*
* @param covarianceMatrices the list of covariance matrices related to the segment.
* @throws IOException if the output stream throws one while writing.
*/
public void writeCovarianceMatrices(final List<CovarianceMatrix> covarianceMatrices)
throws IOException {
writer.append("COVARIANCE_START").append(NEW_LINE);
// Sort to ensure having the matrices in chronological order when
// they are in the same data section (see section 5.2.5.7)
Collections.sort(covarianceMatrices, (mat1, mat2)->mat1.getEpoch().compareTo(mat2.getEpoch()));
for (final CovarianceMatrix covarianceMatrix : covarianceMatrices) {
final String epoch = dateToString(covarianceMatrix.getEpoch().getComponents(timeScale));
writeKeyValue(Keyword.EPOCH, epoch);
if (covarianceMatrix.getFrame() != null ) {
writeKeyValue(Keyword.COV_REF_FRAME, guessFrame(covarianceMatrix.getFrame()));
} else if (covarianceMatrix.getLofType() != null) {
if (covarianceMatrix.getLofType() == LOFType.QSW) {
writeKeyValue(Keyword.COV_REF_FRAME, "RTN");
} else if (covarianceMatrix.getLofType() == LOFType.TNW) {
writeKeyValue(Keyword.COV_REF_FRAME, covarianceMatrix.getLofType().name());
} else {
throw new OrekitException(OrekitMessages.CCSDS_INVALID_FRAME, toString());
}
}
final RealMatrix covRealMatrix = covarianceMatrix.getMatrix();
for (int i = 0; i < covRealMatrix.getRowDimension(); i++) {
writer.append(Double.toString(covRealMatrix.getEntry(i, 0)));
for (int j = 1; j < i + 1; j++) {
writer.append(" ").append(Double.toString(covRealMatrix.getEntry(i, j)));
}
writer.append(NEW_LINE);
}
}
writer.append("COVARIANCE_STOP").append(NEW_LINE).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());
}
}
@Override
public void handleStep(final SpacecraftState s,
final boolean isLast) {
try {
writeEphemerisLine(s.getPVCoordinates(this.frame));
} catch (IOException e) {
throw new OrekitException(e, LocalizedCoreFormats.SIMPLE_MESSAGE,
e.getLocalizedMessage());
}
}
}
/**
* 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);
}
}