diff --git a/assignments/src/MV3500Cohort2022MayJune/homework2/Ashmore/PduTrack.java b/assignments/src/MV3500Cohort2022MayJune/homework2/Ashmore/PduTrack.java
new file mode 100644
index 0000000000000000000000000000000000000000..59ed259f6e77010678f1ff5da4aee78e9b499d1b
--- /dev/null
+++ b/assignments/src/MV3500Cohort2022MayJune/homework2/Ashmore/PduTrack.java
@@ -0,0 +1,1042 @@
+/*
+Copyright (c) 1995-2022 held by the author(s).  All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above copyright
+      notice, this list of conditions and the following disclaimer
+      in the documentation and/or other materials provided with the
+      distribution.
+    * Neither the names of the Naval Postgraduate School (NPS)
+      Modeling Virtual Environments and Simulation (MOVES) Institute
+      https://www.nps.edu and https://www.nps.edu/web/moves
+      nor the names of its contributors may be used to endorse or
+      promote products derived from this software without specific
+      prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+*/
+// TODO move into opendis7 distribution tree in package edu.nps.moves.dis7.utilities.stream;
+
+package MV3500Cohort2022MayJune.homework2.Ashmore;
+
+import edu.nps.moves.dis7.entities.swe.platform.surface._001Poseidon;
+import edu.nps.moves.dis7.enumerations.DisPduType;
+import edu.nps.moves.dis7.enumerations.ForceID;
+import edu.nps.moves.dis7.pdus.EntityID;
+import edu.nps.moves.dis7.pdus.EntityStatePdu;
+import edu.nps.moves.dis7.pdus.EulerAngles;
+import edu.nps.moves.dis7.pdus.Pdu;
+import edu.nps.moves.dis7.pdus.Vector3Double;
+import edu.nps.moves.dis7.utilities.DisTime;
+import edu.nps.moves.dis7.utilities.PduFactory;
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * Create a track from DIS ESPDUs
+ * @author brutzman
+ */
+public class PduTrack
+{   
+    private String          descriptor = new String();
+    private ArrayList<Pdu>  pduList = new ArrayList<>();
+    private EntityStatePdu  initialEspdu;
+    private EntityStatePdu  latestEspdu;
+    private Vector3Double   initialLocation;
+    private Vector3Double   latestLocation;
+    
+    /** waypoint timelineList in seconds */
+    private ArrayList<Float>          timelineList = new ArrayList<>();
+    private ArrayList<Vector3Double> waypointsList = new ArrayList<>();
+    private ArrayList<EulerAngles> eulerAnglesList = new ArrayList<>();
+    
+    private String                             author = new String();
+    private String                 x3dModelIdentifier = new String();
+    private String                 x3dModelName       = "PduTrackInterpolation.x3d";
+    private float             defaultWaypointInterval = -1;
+    private float                     durationSeconds = -1;
+    private String                   x3dTimeSensorDEF = new String();
+    private String         x3dPositionInterpolatorDEF = new String();
+    private String      x3dOrientationInterpolatorDEF = new String();
+    private boolean      addLineBreaksWithinKeyValues = false;
+    /** what kind of timestamp is being used */
+    public    DisTime.TimestampStyle timestampStyle        = DisTime.TimestampStyle.IEEE_ABSOLUTE;
+    /** direct access to pduFactory for creating new PDU instances */
+    protected PduFactory             pduFactory            = new PduFactory(timestampStyle);
+    private   LocalDateTime          recordingStart;
+    private   LocalDateTime          recordingStop;
+    private   String                 todaysDateString      = new String();
+    
+    /** direct access to byteArrayOutputStream */
+    protected ByteArrayOutputStream  byteArrayOutputStream = new ByteArrayOutputStream();
+    /** direct access to DataOutputStream */
+    protected DataOutputStream       dataOutputStream      = new DataOutputStream(byteArrayOutputStream);
+    private   String TRACE_PREFIX = "[" + (PduTrack.class.getSimpleName()) + "] ";
+    
+    /**
+     * Constructor for PduTrack
+     */
+    public PduTrack()
+    {
+        // initialization code here
+        
+        // https://docs.oracle.com/javase/tutorial/datetime/TOC.html
+        // https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/package-summary.html
+        // https://stackoverflow.com/questions/5175728/how-to-get-the-current-date-time-in-java/5175900 (scroll down to java.time)
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("d MMMM yyyy");
+        todaysDateString = LocalDate.now().format(formatter);
+//      System.out.println(TRACE_PREFIX + "today=" + todayString);
+    }
+    
+    /**
+     * Set timestampStyle used by PduFactory
+     * @param newTimestampStyle new value to set
+     */
+    public PduTrack(DisTime.TimestampStyle newTimestampStyle)
+    {
+        timestampStyle = newTimestampStyle;
+        DisTime.setTimestampStyle(newTimestampStyle);
+        // automatic super invocation: return PduTrack();
+    }
+    
+    /**
+     * Get simple descriptor (such as parent class name) for this SimulationManager, used in trace statements
+     * @return simple descriptor name
+     */
+    public String getDescriptor() 
+    {
+        return descriptor;
+    }
+    /**
+     * Set new simple descriptor (such as parent class name) for this SimulationManager, used in trace statements
+     * @param newDescriptor simple descriptor name for this interface
+     * @return same object to permit progressive setters */
+    public PduTrack setDescriptor(String newDescriptor) 
+    {
+        if (newDescriptor != null)
+            this.descriptor = newDescriptor.trim();
+        TRACE_PREFIX = "[" + (PduTrack.class.getSimpleName() + " " + descriptor) + "] ";
+        return this;
+    }
+    /**
+     * Get timestampStyle used by PduFactory
+     * @return current timestampStyle
+     */
+    public DisTime.TimestampStyle getTimestampStyle()
+    {
+        return timestampStyle;
+    }
+    /**
+     * Set timestampStyle used by PduFactory
+     * @param newTimestampStyle the timestampStyle to set
+     * @return same object to permit progressive setters 
+     */
+    public PduTrack setTimestampStyle(DisTime.TimestampStyle newTimestampStyle) 
+    {
+        this.timestampStyle = newTimestampStyle;
+        DisTime.setTimestampStyle(newTimestampStyle);
+        return this;
+    }
+
+    /**
+     * Determine initial location, reset to (0 0 0) if not found
+     * @return current initialLocation
+     */
+    public Vector3Double getInitialLocation() {
+        if (initialLocation == null)
+        {
+            System.out.println (TRACE_PREFIX + "getInitialLocation() not found, isTrackEmpty()=" + isTrackEmpty() + ", returning 0 0 0");
+            return new Vector3Double();
+        }
+        return initialLocation;
+    }
+    /**
+     * Determine current location, reset to (0 0 0) if not found
+     * @return current latestLocation
+     */
+    public Vector3Double getLatestLocation() {
+        if (latestLocation == null)
+        {
+            System.out.println (TRACE_PREFIX + "getLatestLocation() not found, isTrackEmpty()=" + isTrackEmpty() + ", returning 0 0 0");
+            return new Vector3Double();
+        }
+        return latestLocation;
+    }
+    /**
+     * Get individual Pdu from pduList, index must not exceed existing pduList size
+     * @param index for pdu of interest
+     * @return pdu of interest
+     */
+    public Pdu getPdu(int index) throws IndexOutOfBoundsException
+    {
+        if ((index >= pduList.size()) || (index < 0))
+        {
+            System.out.println (TRACE_PREFIX + "getPdu(" + index + ") out of bounds, pduList.size()=" + pduList.size());
+            // then throw exception
+        }
+        return pduList.get(index);
+    }
+    /**
+     * get current pduList
+     * @return current pduList
+     */
+    public ArrayList<Pdu> getPduList() {
+        return pduList;
+    }
+    /**
+     * get current waypointsList
+     * @return current waypointsList
+     */
+    public ArrayList<Vector3Double> getWaypointsList() {
+        return waypointsList;
+    }
+    /**
+     * current eulerAnglesList
+     * @return current eulerAnglesList
+     */
+    public ArrayList<EulerAngles> getEulerAnglesList() {
+        return eulerAnglesList;
+    }
+    /**
+     * Time in seconds corresponding to each PDU
+     * @return current timelineList
+     */
+    public ArrayList<Float> getTimelineList() {
+        return timelineList;
+    }
+    /**
+     * Add PDU, typically ESPDU
+     * @param newPdu new Pdu to add, typically ESPDU
+     * @return same object to permit progressive setters
+     */
+    public PduTrack addPdu(Pdu newPdu)
+    {
+        if (newPdu.getPduType() == DisPduType.ENTITY_STATE)
+        {
+////          EntityStatePdu deepCopyEspdu = new EntityStatePdu();
+//            EntityStatePdu deepCopyEspdu = pduFactory.makeEntityStatePdu();
+//            deepCopyEspdu.setTimestamp        (((EntityStatePdu)newPdu).getTimestamp());
+//            deepCopyEspdu.setMarking          (((EntityStatePdu)newPdu).getMarking());
+//            deepCopyEspdu.setEntityID         (((EntityStatePdu)newPdu).getEntityID());
+//            deepCopyEspdu.setForceId          (((EntityStatePdu)newPdu).getForceId());
+//            deepCopyEspdu.setEntityType       (((EntityStatePdu)newPdu).getEntityType());
+//            deepCopyEspdu.setEntityLocation   (((EntityStatePdu)newPdu).getEntityLocation());
+//            deepCopyEspdu.setEntityOrientation(((EntityStatePdu)newPdu).getEntityOrientation());
+            
+            EntityStatePdu deepCopyEspdu = ((EntityStatePdu)newPdu).copy();
+            pduList.add(deepCopyEspdu);
+//          alternative trials:
+//          pduList.add(((EntityStatePdu)newPdu).copyDataOutputStream());
+//          pduList.add(((EntityStatePdu)newPdu).copy());
+            
+            if (initialLocation == null)
+            {
+                initialEspdu    = deepCopyEspdu; // must save object since pduList might contain more than ESPDUs
+                initialLocation = deepCopyEspdu.getEntityLocation();
+            }
+            latestEspdu         = deepCopyEspdu; // must save object since pduList might contain more than ESPDUs
+            latestLocation      = deepCopyEspdu.getEntityLocation();
+        }
+        else pduList.add(newPdu); // TODO copy() - careful, must be a new object and not a reference
+        return this;
+    }
+    /**
+     * clear all PDUs
+     * @return same object to permit progressive setters
+     */
+    public PduTrack clearPduLists()
+    {
+           getPduList().clear();
+          waypointsList.clear();
+        eulerAnglesList.clear();
+           timelineList.clear();
+           initialEspdu = null;
+            latestEspdu = null;
+        initialLocation = null;
+         latestLocation = null;
+        return this;
+    }
+    
+    private String normalizeNameToken(String candidateDEF)
+    {
+        return candidateDEF.replace(" ", "").replace("-", "")
+                           .replace("(", "").replace(")", "")
+                           .replace("[", "").replace("])", "");
+    }
+
+    /**
+     * Provide DEF value if not defined by program
+     * @return current x3dTimeSensorDEF
+     */
+    public String getX3dTimeSensorDEF() {
+        if    (x3dTimeSensorDEF.isEmpty())
+               x3dTimeSensorDEF = normalizeNameToken(getDescriptor()) + "Clock";
+        return x3dTimeSensorDEF;
+    }
+
+    /**
+     * Set DEF value for X3D node
+     * @param x3dTimeSensorDEF the x3dTimeSensorDEF to set
+     */
+    public void setX3dTimeSensorDEF(String x3dTimeSensorDEF) {
+        this.x3dTimeSensorDEF = normalizeNameToken(x3dTimeSensorDEF);
+    }
+
+    /**
+     * Provide DEF value if not defined by program
+     * @return current x3dPositionInterpolatorDEF
+     */
+    public String getX3dPositionInterpolatorDEF() {
+        if    (x3dPositionInterpolatorDEF.isEmpty())
+               x3dPositionInterpolatorDEF = normalizeNameToken(getDescriptor()) + "Positions";
+        return x3dPositionInterpolatorDEF;
+    }
+
+    /**
+     * Set DEF value for X3D node
+     * @param x3dPositionInterpolatorDEF the x3dPositionInterpolatorDEF to set
+     */
+    public void setX3dPositionInterpolatorDEF(String x3dPositionInterpolatorDEF) {
+        this.x3dPositionInterpolatorDEF = normalizeNameToken(x3dPositionInterpolatorDEF);
+    }
+
+    /**
+     * Provide DEF value if not defined by program
+     * @return current x3dOrientationInterpolatorDEF
+     */
+    public String getX3dOrientationInterpolatorDEF() {
+        if    (x3dOrientationInterpolatorDEF.isEmpty())
+               x3dOrientationInterpolatorDEF = normalizeNameToken(getDescriptor()) + "Orientations";
+        return x3dOrientationInterpolatorDEF;
+    }
+
+    /**
+     * Set DEF value for X3D node
+     * @param x3dOrientationInterpolatorDEF the x3dOrientationInterpolatorDEF to set
+     */
+    public void setX3dOrientationInterpolatorDEF(String x3dOrientationInterpolatorDEF) {
+        this.x3dOrientationInterpolatorDEF = normalizeNameToken(x3dOrientationInterpolatorDEF);
+    }
+
+    /**
+     * Sort all PDUs by timestamp
+     * @see <a href="https://stackoverflow.com/questions/16252269/how-to-sort-an-arraylist">StackOverflow: How to sort an ArrayList?</a>
+     * @see <a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/doc-files/coll-overview.html">Collections Framework Overview</a>
+     * @return same object to permit progressive setters
+     */
+    public PduTrack sortPdus()
+    {
+        Collections.sort(pduList, new Comparator<Pdu>() {
+            @Override
+            public int compare(Pdu lhs, Pdu rhs)
+            {
+                // -1 less than, 1 greater than, 0 equal
+                if      (lhs.occursBefore(rhs))
+                         return -1;
+                else if (lhs.occursSameTime(rhs))
+                         return 0;
+                else     return 1;
+            }
+        });
+        return this;
+    }
+    /**
+     * Reverse order of PDU list
+     * @see <a href="https://stackoverflow.com/questions/10766492/what-is-the-simplest-way-to-reverse-an-arraylist">StackOverflow: What is the Simplest Way to Reverse an ArrayList?</a>
+     * @see <a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/doc-files/coll-overview.html">Collections Framework Overview</a>
+     * @return same object to permit progressive setters
+     */
+    public PduTrack reversePdus()
+    {
+        Collections.reverse(pduList);
+        return this;
+    }
+
+    /**
+     * Determine whether any ESPDUs have been received by this track
+     * @return whether track is empty
+     */
+    public boolean isTrackEmpty()
+    {
+        return (getEspduCount() == 0);
+    }
+    /**
+     * Count ESPDUs in pduList
+     * @return number of ESPDUs in pduList
+     */
+    public int getEspduCount()
+    {
+        int counter = 0;
+        for (Pdu nextPdu : getPduList())
+        {
+            if (nextPdu.getPduType() == DisPduType.ENTITY_STATE)
+               counter += 1;
+        }
+        return counter;
+    }
+    /**
+     * Compute track duration in timestamp ticks
+     * @return duration in timestamp ticks between initial and final ESPDU timestamps in waypointList
+     */
+    public int getTotalDurationTicks()
+    {
+        int    initialTime = -1;
+        int      finalTime = -1;
+        int  durationTicks = -1; // used if pduList is empty
+        
+        // must skip through pduList since non-ESPDU PDUs may be present
+        for (Pdu nextPdu : getPduList())
+        {
+            if (nextPdu.getPduType() == DisPduType.ENTITY_STATE)
+            {
+                if (initialTime == -1)
+                    initialTime = nextPdu.getTimestamp();
+                finalTime = nextPdu.getTimestamp();
+            }
+        }
+        if ((initialTime >= 0) && (finalTime >= 0))
+             durationTicks = (finalTime - initialTime);
+        if (getPduList().isEmpty())
+        {
+            System.out.println(TRACE_PREFIX + "getTrackDuration() computed illegal duration=" + durationTicks + " due to empty pdu list");
+        }
+        else if ((durationTicks <= 0) && (defaultWaypointInterval <= 0))
+        {
+            System.out.println(TRACE_PREFIX + "getTrackDuration() computed illegal duration=" + durationTicks + " due to illegal pdu list");
+        }
+        return durationTicks;
+    }
+    /**
+     * Compute track duration in seconds
+     * @return duration in seconds between initial and final ESPDU timestamps in waypointList
+     */
+    public float getTotalDurationSeconds()
+    {
+        if (defaultWaypointInterval > 0)
+        {
+            return getEspduCount() * defaultWaypointInterval;
+        }
+        else if (getTotalDurationTicks() < 0)
+               durationSeconds = getTotalDurationTicks() * 1.0f; // TODO convert
+        return durationSeconds;
+    }
+    /**
+     * Create waypoints and angles using all ESPDU points, with no linear regression or array reduction.
+     * @return same object to permit progressive setters
+     */
+    public PduTrack createRawWaypoints()
+    {
+        // https://stackoverflow.com/questions/6536094/java-arraylist-copy
+        
+           timelineList.clear();
+          waypointsList.clear();
+        eulerAnglesList.clear();
+        float clock = 0.0f;
+        for (int i = 0; i < pduList.size(); i++)
+        {
+            Pdu nextPdu = pduList.get(i);
+            if (nextPdu.getPduType() == DisPduType.ENTITY_STATE)
+            {
+                EntityStatePdu espdu = (EntityStatePdu)nextPdu;
+                if (defaultWaypointInterval > 0)
+                {
+                    timelineList.add(clock);
+                    clock += defaultWaypointInterval;
+                }
+                else
+                {
+                    timelineList.add(espdu.getTimestamp() * 1.0f); // TODO convert
+                }
+                   waypointsList.add(espdu.getEntityLocation());
+                 eulerAnglesList.add(espdu.getEntityOrientation());
+            }
+        }
+        return this;
+    }
+    /** 
+     * Utility method to create TimeSensor
+     * @return TimeSensor string in XML format
+     */
+    public String createX3dTimeSensorString()
+    {
+        StringBuilder sb = new StringBuilder();
+        sb.append("    <TimeSensor");
+        sb.append(" DEF='").append(getX3dTimeSensorDEF()).append("'");
+        sb.append(" cycleInterval='").append(String.valueOf(getTotalDurationSeconds())).append("'");
+        sb.append(" loop='true'");
+        sb.append("/>").append("\n");
+        
+        return sb.toString();
+    }
+    /**
+     * Create PositionInterpolator from Pdu list
+     * @return X3D PositionInterpolator as string
+     */
+    public String createX3dPositionInterpolatorString()
+    {
+        StringBuilder sb = new StringBuilder();
+        sb.append("    <PositionInterpolator");
+        sb.append(" DEF='").append(getX3dPositionInterpolatorDEF()).append("'");
+        sb.append(" key='");
+        for (int i = 0; i < timelineList.size(); i++)
+        {
+            float nextDuration = timelineList.get(i) * 1.0f; // TODO convert
+            sb.append(String.valueOf(nextDuration));
+            if (i < timelineList.size() - 1)
+                sb.append(" ");
+        }
+        sb.append("'");
+        sb.append(" keyValue='");
+        for (int i = 0; i < waypointsList.size(); i++)
+        {
+            if (hasAddLineBreaksWithinKeyValues())
+                sb.append("\n");
+            Vector3Double nextPosition = waypointsList.get(i);
+            sb.append(String.valueOf(nextPosition.getX())).append(" ")
+              .append(String.valueOf(nextPosition.getY())).append(" ")
+              .append(String.valueOf(nextPosition.getZ()));
+            if (i < waypointsList.size() - 1)
+                sb.append(",");
+        }
+        sb.append("'");
+        sb.append("/>").append("\n");
+        
+        return sb.toString();
+    }
+    /**
+     * Create OrientationInterpolator from Pdu list
+     * TODO preliminary support only includes horizontal rotation.
+     * @return X3D OrientationInterpolator as string
+     */
+    public String createX3dOrientationInterpolatorString()
+    {
+        StringBuilder sb = new StringBuilder();
+        sb.append("    <OrientationInterpolator");
+        sb.append(" DEF='").append(getX3dOrientationInterpolatorDEF()).append("'");
+        sb.append(" key='");
+        for (int i = 0; i < timelineList.size(); i++)
+        {
+            float nextDuration = timelineList.get(i) * 1.0f; // TODO convert
+            sb.append(String.valueOf(nextDuration));
+            if (i < timelineList.size() - 1)
+                sb.append(" ");
+        }
+        sb.append("'");
+        sb.append(" keyValue='");
+        for (int i = 0; i < eulerAnglesList.size(); i++)
+        {
+            if (hasAddLineBreaksWithinKeyValues())
+                sb.append("\n");
+            EulerAngles nextEulerAngle = new EulerAngles();
+            float axisX = 0.0f;
+            float axisY = 1.0f;
+            float axisZ = 0.0f;
+            float angle = 0.0f; // radians
+            
+            nextEulerAngle = eulerAnglesList.get(i);
+            angle = nextEulerAngle.getTheta();
+            
+            sb.append(String.valueOf(axisX)).append(" ")
+              .append(String.valueOf(axisY)).append(" ")
+              .append(String.valueOf(axisZ)).append(" ")
+              .append(String.valueOf(angle));
+            if (i < eulerAnglesList.size() - 1)
+                sb.append(",");
+        }
+        sb.append("'");
+        sb.append("/>").append("\n");
+        
+        return sb.toString();
+    }
+
+    /**
+     * Get name of author used as creator of X3D model
+     * @return current author
+     */
+    public String getAuthor() {
+        return author;
+    }
+
+    /**
+     * Set name of author used as creator of X3D model
+     * @param author the author to set
+     */
+    public void setAuthor(String author) {
+        if  (author == null)
+             author = new String();
+        author = author.trim();
+        this.author = author;
+    }
+
+    /**
+     * Get name of online url identifier for X3D model
+     * @return current x3dModelIdentifier
+     */
+    public String getX3dModelIdentifier() {
+        return x3dModelIdentifier;
+    }
+
+    /**
+     * Set name of online url identifier for X3D model
+     * @param x3dModelIdentifier the x3dModelIdentifier to set
+     */
+    public void setX3dModelIdentifier(String x3dModelIdentifier) {
+        if  (x3dModelIdentifier == null)
+             x3dModelIdentifier = new String();
+        x3dModelIdentifier = x3dModelIdentifier.trim();
+        if (!x3dModelIdentifier.startsWith("http://") && !x3dModelIdentifier.startsWith("https://"))
+            System.out.println(TRACE_PREFIX + "warning, identifier typically begins with https:// or http://");
+        else this.x3dModelIdentifier = x3dModelIdentifier;
+    }
+
+    /**
+     * File name for X3D model production
+     * @return current x3dModelName
+     */
+    public String getX3dModelName() {
+        return x3dModelName;
+    }
+    /**
+     * File name for X3D model production
+     * @param x3dModelName the x3dModelName to set
+     */
+    public void setX3dModelName(String x3dModelName) {
+        if  (x3dModelName == null)
+             x3dModelName = new String();
+        x3dModelName = x3dModelName.trim();
+        this.x3dModelName = x3dModelName;
+    }
+
+    /**
+     * Verbose (but more readable) output of numeric arrays in X3D model
+     * @return current addLineBreaksWithinKeyValues
+     */
+    public boolean hasAddLineBreaksWithinKeyValues() {
+        return addLineBreaksWithinKeyValues;
+    }
+
+    /**
+     * Verbose (but more readable) output of numeric arrays in X3D model
+     * @param addLineBreaksWithinKeyValues the addLineBreaksWithinKeyValues to set
+     */
+    public void setAddLineBreaksWithinKeyValues(boolean addLineBreaksWithinKeyValues) {
+        this.addLineBreaksWithinKeyValues = addLineBreaksWithinKeyValues;
+    }
+    /**
+     * Create full X3D interpolator model from Pdu list, assembling sections of scene graph
+     * @return X3D model as string
+     */
+    public String createX3dModel()
+    {
+        StringBuilder sb = new StringBuilder();
+        sb.append(createX3dModelHeaderString());
+        sb.append(createX3dTimeSensorString());
+        sb.append(createX3dPositionInterpolatorString());
+        sb.append(createX3dOrientationInterpolatorString());
+        sb.append(createX3dRoutesGeometryFooterString());
+        return sb.toString();
+    }
+    /**
+     * Create PositionInterpolator from Pdu list
+     * @return X3D PositionInterpolator as string
+     */
+    public String createX3dModelHeaderString()
+    {        
+        StringBuilder sb = new StringBuilder();
+        
+        sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>").append("\n");
+        sb.append("<!DOCTYPE X3D PUBLIC \"ISO//Web3D//DTD X3D 4.0//EN\" \"https://www.web3d.org/specifications/x3d-4.0.dtd\">").append("\n");
+        sb.append("<X3D profile='Interchange' version='4.0' xmlns:xsd='http://www.w3.org/2001/XMLSchema-instance' xsd:noNamespaceSchemaLocation='https://www.web3d.org/specifications/x3d-4.0.xsd'>").append("\n");
+        sb.append("  <head>").append("\n");
+        if (!getX3dModelName().isEmpty())
+            sb.append("    <meta content='").append(getX3dModelName()).append("' name='title'/>").append("\n");
+        sb.append("    <meta content='Conversion of ESPDU track into X3D animation interpolators and LineSet.' name='description'/>").append("\n");
+        
+        sb.append("    <meta content='1 January 2022' name='created'/>").append("\n");
+        sb.append("    <meta content='").append(todaysDateString).append("' name='modified'/>").append("\n");
+        if (!getAuthor().isEmpty())
+            sb.append("    <meta content='").append(getAuthor()).append("' name='creator'/>").append("\n");
+        if (!getX3dModelIdentifier().isEmpty())
+            sb.append("    <meta content='").append(getX3dModelIdentifier()).append("' name='identifier'/>").append("\n");
+        
+        sb.append("    <meta content='PduTrack utility, opendis7-java Library https://github.com/open-dis/opendis7-java' name='generator'/>").append("\n");
+        sb.append("    <meta content='NPS MOVES MV3500 Networked Graphics https://gitlab.nps.edu/Savage/NetworkedGraphicsMV3500' name='reference'/>").append("\n");
+        sb.append("    <meta content='X3D Resources https://www.web3d.org/x3d/content/examples/X3dResources.html' name='reference'/>").append("\n");
+        sb.append("    <meta content='X3D Scene Authoring Hints https://www.web3d.org/x3d/content/examples/X3dSceneAuthoringHints.html' name='reference'/>").append("\n");
+        sb.append("    <meta content='X3D Tooltips https://www.web3d.org/x3d/tooltips/X3dTooltips.html' name='reference'/>").append("\n");
+        sb.append("    <meta content='X3D Validator https://savage.nps.edu/X3dValidator' name='reference'/>").append("\n");
+        sb.append("    <meta content='Open source https://raw.githubusercontent.com/open-dis/opendis7-java/master/license.html' name='license'/>").append("\n");
+        sb.append("  </head>").append("\n");
+        sb.append("  <Scene>").append("\n");
+        sb.append("    <WorldInfo title='PduTrackInterpolation.x3d'/>").append("\n");
+
+        return sb.toString(); 
+    }
+    /**
+     * Create X3D ROUTEs and footer to connect TimeSensor to interpolators
+     * @return X3D PositionInterpolator as string
+     */
+    public String createX3dRoutesGeometryFooterString()
+    {
+        StringBuilder sb = new StringBuilder();
+        
+        sb.append("    <ROUTE fromField='fraction_changed' fromNode='")
+          .append(getX3dTimeSensorDEF())
+          .append("' toField='set_fraction' toNode='")
+          .append(getX3dPositionInterpolatorDEF())
+          .append("'/>").append("\n");
+        sb.append("    <ROUTE fromField='fraction_changed' fromNode='")
+          .append(getX3dTimeSensorDEF())
+          .append("' toField='set_fraction' toNode='")
+          .append(getX3dOrientationInterpolatorDEF())
+          .append("'/>").append("\n");
+        
+        sb.append("    <Shape>").append("\n");
+        sb.append("      <Appearance DEF='TrackAppearance'>").append("\n");
+        sb.append("        <Material emissiveColor='0.2 0.8 0.8'/>").append("\n");
+        sb.append("      </Appearance>").append("\n");
+        sb.append("      <LineSet vertexCount='").append(waypointsList.size()).append("'>").append("\n");
+        sb.append("        <Coordinate point='");
+        for (int i = 0; i < waypointsList.size(); i++)
+        {
+            if (hasAddLineBreaksWithinKeyValues())
+                sb.append("\n");
+            Vector3Double nextPosition = waypointsList.get(i);
+            sb.append(String.valueOf(nextPosition.getX())).append(" ")
+              .append(String.valueOf(nextPosition.getY())).append(" ")
+              .append(String.valueOf(nextPosition.getZ()));
+            if (i < waypointsList.size() - 1)
+                sb.append(",");
+        }
+        sb.append("'/>").append("\n");
+        sb.append("      </LineSet>").append("\n");
+        sb.append("    </Shape>").append("\n");
+        sb.append("    <Transform DEF='AnimationTransform'>").append("\n");
+        sb.append("      <Transform rotation='0 0 1 1.57'>").append("\n");
+        sb.append("        <Shape>").append("\n");
+        sb.append("          <Appearance USE='TrackAppearance'/>").append("\n");
+        sb.append("          <Cone bottomRadius='0.5'/>").append("\n");
+        sb.append("        </Shape>").append("\n");
+        sb.append("      </Transform>").append("\n");
+        sb.append("    </Transform>").append("\n");
+        sb.append("    <ROUTE fromField='value_changed' fromNode='")
+          .append(getX3dPositionInterpolatorDEF())
+          .append("' toField='translation' toNode='AnimationTransform'/>").append("\n");
+        sb.append("    <ROUTE fromField='value_changed' fromNode='")
+          .append(getX3dOrientationInterpolatorDEF())
+          .append("' toField='rotation' toNode='AnimationTransform'/>").append("\n");
+        
+        sb.append("  </Scene>").append("\n");
+        sb.append("</X3D>").append("\n");
+        
+        return sb.toString();
+    }
+
+    /**
+     * get defaultWaypointInterval
+     * @return current wayPointInterval
+     */
+    public float getDefaultWaypointInterval() {
+        return defaultWaypointInterval;
+    }
+
+    /**
+     * Set uniform waypoint interval (currently for testing)
+     * @param newWaypointInterval the wayPointInterval to set, in seconds, must be greater than zero
+     * @return same object to permit progressive setters */
+    public PduTrack setDefaultWaypointInterval(float newWaypointInterval) {
+        if (newWaypointInterval > 0.0)
+            this.defaultWaypointInterval = newWaypointInterval;
+        else 
+        {
+            System.out.println(TRACE_PREFIX + "error in setWaypointInterval(newWaypointInterval=" + newWaypointInterval + ") must be greater than zero");
+            return this;
+        }
+            
+        float clock = 0.0f;
+        if (!timelineList.isEmpty())
+        {   
+            ArrayList<Float> newTimelineList = new ArrayList<>();
+            for (int i = 0; i < getEspduCount(); i++)
+            {
+                newTimelineList.add(clock);
+                clock += defaultWaypointInterval;
+            }
+            timelineList = newTimelineList; // TO Array copy?
+        }
+        return this;
+    }
+    /** whether or not to insert commas between hex values */
+    private boolean insertCommas = true;
+    /**
+     * determine whether comma insertion is turned on
+     * @return whether or not to insert commas between hex values
+     */
+    public boolean hasInsertCommas() {
+        return insertCommas;
+    }
+    /**
+     * set whether comma insertion is turned on
+     * @param insertCommas the insertCommas value to set
+     */
+    public void setInsertCommas(boolean insertCommas) {
+        this.insertCommas = insertCommas;
+    }
+    /**
+     * Convert byte array to hex string
+     * @param bytes input data
+     * @param insertCommas whether to insert commas between hex values
+     * @return hex string
+     */
+    public String bytesToHex(byte[] bytes, boolean insertCommas)
+    {
+        this.setInsertCommas(insertCommas);
+        return bytesToHex(bytes);
+    }
+    /**
+     * Convert byte array to hex string
+     * @param bytes input data
+     * @see <a href="https://stackoverflow.com/questions/9655181/how-to-convert-a-byte-array-to-a-hex-string-in-java">https://stackoverflow.com/questions/9655181/how-to-convert-a-byte-array-to-a-hex-string-in-java</a>
+     * @return hex string
+     */
+    public static String bytesToHex(byte[] bytes)
+    {
+        boolean insertCommas = true;
+        final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray();
+        char[] hexChars = new char[bytes.length * 2];
+        StringBuilder sb = new StringBuilder();
+        for (int j = 0; j < bytes.length; j++) {
+            int v = bytes[j] & 0xFF;
+            hexChars[j * 2]     = HEX_ARRAY[v >>> 4];
+            hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
+//          if (!(hexChars[j * 2] == '0')) // omit leading zero
+            sb.append(hexChars[j * 2]);
+            sb.append(hexChars[j * 2 + 1]);
+            if (insertCommas && (j < bytes.length - 1))
+                sb.append(", ");
+        }
+        return sb.toString();
+    }
+    /**
+     * Report current PDU information to console
+     * @param anEspdu EntityStatePdu of interest
+     * @return same object to permit progressive setters
+     */
+    public PduTrack reportPdu(EntityStatePdu anEspdu)
+    {
+        System.out.println (
+                String.format("%s", anEspdu.getMarkingString().trim()) + ", " +
+                DisTime.convertToString(anEspdu.getTimestamp()) + " (" +
+                String.format("%08d", anEspdu.getTimestamp()) + "), " + 
+                "EntityID=(" + 
+                anEspdu.getEntityID().getSiteID() + ", " +
+                anEspdu.getEntityID().getApplicationID() + ", " + 
+                anEspdu.getEntityID().getEntityID() + "), " +
+                "location=(" + 
+                String.format("%4.1f", anEspdu.getEntityLocation().getX()) + ", " +
+                String.format("%4.1f", anEspdu.getEntityLocation().getY()) + ", " + 
+                String.format("%4.1f", anEspdu.getEntityLocation().getZ()) + ")"
+//              + " " + espdu_1.getEntityLinearVelocity().toString()
+        );
+        return this;
+    }
+    
+    /** Flush all buffers to reduce console scrambling while threaded
+     */
+    protected void flushBuffers()
+    {
+        try
+        {
+                 dataOutputStream.flush(); 
+            byteArrayOutputStream.flush();
+            byteArrayOutputStream.reset();
+            System.err.flush(); 
+            System.out.flush();
+        }
+        catch (IOException ioe)
+        {
+            System.out.println(TRACE_PREFIX + "flushBuffers() IOException: " + ioe.getMessage());
+        }
+    }
+    
+    /** Self test to check basic operation, invoked by main()
+     */
+    @SuppressWarnings("SleepWhileInLoop")
+    public void selfTest()
+    {
+        final int TOTAL_PDUS = 5;
+        System.out.println(TRACE_PREFIX + "selfTest() start...");
+      
+        PduTrack pduTrack = new PduTrack();
+        pduTrack.setDescriptor("PduTrack Self Test");
+        pduTrack.setAuthor("Don Brutzman");
+        pduTrack.setX3dModelIdentifier("https://gitlab.nps.edu/Savage/NetworkedGraphicsMV3500/-/blob/master/examples/src/OpenDis7Examples/PduTrackInterpolation.x3d");
+        pduTrack.setDefaultWaypointInterval(1.0f); // experimentation with timestamp values
+
+        DisTime.setEpochLvcNow();
+        recordingStart = LocalDateTime.now();
+        Instant epoch = DisTime.getEpochLvc();
+        System.out.println(TRACE_PREFIX + "DisTime.hasEpochLvc()=" + DisTime.hasEpochLvc() + 
+                                         ", DisTime.getEpochLvc()=" + epoch + 
+                                         ", Instant.now()=" + Instant.now());
+        
+        EntityID entityID_123 = new EntityID();
+        entityID_123.setSiteID(1).setApplicationID(2).setEntityID(3); // made-up example ID;
+        // TODO someday, use enumerations; is there a unique site triplet for MOVES Institute?
+
+        for (int i = 0; i < TOTAL_PDUS; i++) // create espdus and add each to track pduList
+        {
+//          EntityStatePdu espdu = new EntityStatePdu();
+            EntityStatePdu espdu = pduFactory.makeEntityStatePdu(); // TODO check Pdu.Type
+            espdu.setTimestamp(DisTime.getCurrentDisTimestamp()); // chooses appropriate version
+            espdu.setMarking("ESPDU " + i);
+            espdu.setEntityLocation(i, i, i);
+            espdu.setEntityOrientation(0, (float)(45.0 * Math.PI / 180.0), 0);
+            espdu.setEntityID(entityID_123);
+            espdu.setForceId(ForceID.FRIENDLY);
+            espdu.setEntityType(new _001Poseidon()); // note import statement above
+            pduTrack.addPdu(espdu); // create copy
+            reportPdu(espdu);
+            try
+            {
+                Thread.sleep(100l);
+            }
+            catch (InterruptedException ie)
+            {
+                System.out.println(TRACE_PREFIX + "exceptional sleep dulay when generating ESPDUs: " + ie.getMessage());
+            }
+        }
+//        System.out.println(TRACE_PREFIX + "reversePdus() then sortPdus() to test track operations");
+//        pduTrack.reversePdus(); // test
+//        pduTrack.sortPdus();    // restore
+        pduTrack.createRawWaypoints(); // copies all ESPDU points to waypoints
+        
+        System.out.println(TRACE_PREFIX + "getEspduCount()="              + pduTrack.getEspduCount());
+        System.out.println(TRACE_PREFIX + "getDefaultWaypointInterval()=" + pduTrack.getDefaultWaypointInterval());
+        System.out.println(TRACE_PREFIX + "getTotalDurationSeconds()="    + pduTrack.getTotalDurationSeconds());
+        
+        System.out.println("=================================");
+        System.out.println("PduTrack pduList marshalling checks");
+        System.out.println("= = = = = = = = = = = = = = = = =");
+        try
+        {
+//          int BYTE_BUFFER_SIZE = 400; // TODO what is expected max buffer size?
+            for (int i = 0; i < TOTAL_PDUS; i++)
+            {
+                Pdu pdu = pduTrack.getPduList().get(i);
+                if (!(pdu instanceof EntityStatePdu))
+                    continue; // skip remainder of this loop
+                EntityStatePdu espdu = (EntityStatePdu) pdu;
+                System.out.println("espdu from pduTrack pduList");
+                reportPdu(espdu);
+                byte[] byteArray = espdu.marshal();
+                System.out.println("espdu.marshal() byteArray:                              " + bytesToHex(byteArray));
+                flushBuffers();
+                
+                ByteBuffer byteBuffer = ByteBuffer.allocate(byteArray.length);
+                espdu.marshal(byteBuffer);
+                System.out.println("espdu.marshal(byteBuffer):                              " + bytesToHex(byteBuffer.array()));
+                flushBuffers();
+                
+                espdu.marshal(dataOutputStream);
+                byte[] byteArrayDOS = byteArrayOutputStream.toByteArray();
+                System.out.println("espdu.marshal(dataOutputStream):                        " + bytesToHex(byteArrayDOS));
+                flushBuffers();
+
+                System.out.println(); // - - - - - - - - - - - - - - - - -
+                
+                System.out.println("espdu.copyByteBuffer()");
+                reportPdu(espdu.copyByteBuffer());
+                byte[] byteArrayCopy = espdu.copyByteBuffer().marshal();
+                System.out.println("espdu.copyByteBuffer().marshal() byteArray:             " + bytesToHex(byteArrayCopy));
+                flushBuffers();
+                
+                ByteBuffer byteBufferCopy = ByteBuffer.allocate(byteArray.length); // TODO is there a better way to reset?
+                espdu.copyByteBuffer().marshal(byteBufferCopy);
+                System.out.println("espdu.copyByteBuffer().marshal(byteBufferCopy):         " + bytesToHex(byteBufferCopy.array()));
+                flushBuffers();
+                
+                espdu.copyByteBuffer().marshal(dataOutputStream);
+                byte[] byteArrayDosCopy = byteArrayOutputStream.toByteArray();
+                System.out.println("espdu.copyByteBuffer().marshal(dataOutputStream):       " + bytesToHex(byteArrayDosCopy));
+                flushBuffers();
+
+                System.out.println(); // - - - - - - - - - - - - - - - - -
+                
+                System.out.println("espdu.copyDataOutputStream()");
+                reportPdu(espdu.copyDataOutputStream());
+                byte[] byteArrayCopyDOS = espdu.copyDataOutputStream().marshal();
+                System.out.println("espdu.copyDataOutputStream().marshal() byteArray:       " + bytesToHex(byteArrayCopyDOS));
+                flushBuffers();
+                
+                ByteBuffer byteBufferCopyDOS = ByteBuffer.allocate(byteArray.length); // TODO is there a better way to reset?
+                espdu.copyDataOutputStream().marshal(byteBufferCopyDOS);
+                System.out.println("espdu.copyDataOutputStream().marshal(byteBufferCopy):   " + bytesToHex(byteBufferCopyDOS.array()));
+                flushBuffers();
+                
+                espdu.copyDataOutputStream().marshal(dataOutputStream);
+                byte[] byteArrayDosCopy2 = byteArrayOutputStream.toByteArray();
+                System.out.println("espdu.copyDataOutputStream().marshal(dataOutputStream): " + bytesToHex(byteArrayDosCopy2));
+                flushBuffers();
+                System.out.println();
+                
+                System.out.println("= = = = = = = = = = = = = = = = =");
+            }
+        }
+        catch(Exception e)
+        {
+            System.out.println(TRACE_PREFIX + "Marshalling test exception: " + e.getMessage());
+        }
+        System.out.println("=================================");
+        pduTrack.setAddLineBreaksWithinKeyValues(true);
+        System.out.println(pduTrack.createX3dModel()); // 
+        System.out.println("=================================");
+
+        recordingStop = LocalDateTime.now();
+        System.out.println(TRACE_PREFIX + "selfTest() complete.");
+    }
+    
+    /**
+     * Main method for testing.
+     * @see <a href="https://docs.oracle.com/javase/tutorial/getStarted/application/index.html">Java Tutorials: A Closer Look at the "Hello World!" Application</a>
+     * @param args [address, port, descriptor] command-line arguments are an array of optional String parameters that are passed from execution environment during invocation
+     */
+    public static void main(String[] args)
+    {
+        System.out.println("*** PduTrack.main() self test started...");
+          
+        PduTrack pduTrack = new PduTrack();
+        
+        pduTrack.setDescriptor("main() self test");
+        
+        pduTrack.selfTest();
+        
+        System.out.println("*** PduTrack.main() self test complete.");
+    }
+
+}