A Java Class for Reading
Amtrak Performance History

Version 0

Bill Seymour
2018-02-03

Copyright Bill Seymour 2018.
Distributed under the Boost Software License, Version 1.0.
(See accompanying file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)


Contents


Abstract

This paper describes an open-source Java class that provides a mechanism for reading historical performance of Amtrak trains. The class has several overloads of a read() method to do the deed; and as extra added attractions, it supplies methods for adding hyphens to "yyyymmdd" dates, for generating "hh:mm" strings from minutes after midnight, and for converting weekdays back and forth between integer and character representations.

This began its life as a C++ library; and the author translated that into Java for no better reason than that he could.

The source file is AmtrakHistory.java. It’s also in Appendix A so that you can peruse it first before deciding that you want to use it.

Appendix B contains an example of using the class.

This paper, and the Java code it describes, are distributed under the Boost Software License, which isn’t viral as the GPL and others are said to be. (This software isn’t part of Boost; I just like the license.)


Some manual work required

In the current version, the data are generated at https://juckins.net/amtrak_status/archive/html/history.php. The user needs to manually copy and paste the detail lines in the HTML table produced by that Web page into plain text files. It’s those files that get read. It’s hoped that a future version will require less manual intervention.


Synposis

package atkhist;

public class AmtrakHistory
{
    public static final int NO_TIME = Integer.MAX_VALUE;
    public static final int ONE_DAY = 24 * 60;

    public static class Traintime implements Comparable<Traintime>
    {
        public String date();
        public int weekday();
        public int time();
        public int late();

        @Override public int compareTo(Traintime);
        @Override public boolean equals(Object);
        @Override public int hashCode();

        public void backOneDay();
    }

    public static String read(String filename, Collection<Traintime>);

    public static String read(String filename,
                              Collection<Traintime>,
                              Collection<Integer> weekdays);

    public static String read(boolean is_departure,
                              String train_number,
                              String station_code,
                              Collection<Traintime>);

    public static String read(boolean is_departure,
                              String train_number,
                              String station_code,
                              Collection<Traintime>,
                              Collection<Integer>);

    public static String read(boolean is_departure,
                              int train_number,
                              String station_code,
                              Collection<Traintime>);

    public static String read(boolean is_departure,
                              int train_number,
                              String station_code,
                              Collection<Traintime>,
                              Collection<Integer>);

    public static final int BAD_DAY = 0;
    public static final int MO = 1;
    public static final int TU = 2;
    public static final int WE = 3;
    public static final int TH = 4;
    public static final int FR = 5;
    public static final int SA = 6;
    public static final int SU = 7;
    public static final int ALL_DAYS = 8;

    public static final String[] WEEKDAY_ABBRV =
    {
        "??", "Mo", "Tu", "We", "Th", "Fr", "Sa", "Su", "??"
    };
    public static final String[] WEEKDAY_NAMES =
    {
        "No such day", "Monday", "Tuesday", "Wednesday",
        "Thursday", "Friday", "Saturday", "Sunday", "All Days"
    };

    public static int makeWeekday(String);
    public static int makeWeekday(String, int starting_position);
    public static int makeWeekday(char, char);

    public static String readableDate(String);

    public static String readableTime(int minutes, String minus_sign);
    public static String readableTime(int);
    public static String htmlTime(int);
}


Detailed Descriptions

The data are read from plain text files into a Collection<AmtrakHistory.Traintime>.

For trains that run less than daily, the input files may optionally contain a list of weekdays on which the train is scheduled to run; and if the file actually contains such a list, the weekdays will be read into a Collection<Integer>. These values will be 1 for Monday through 7 for Sunday which are commonly used in the travel industry.

AmtrakHistory’s fields are all static, so all of its methods are as well; but no constructor is defined at all, which means that the class has a default constructor. If you use this class heavily and don’t want to strew “AmtrakHistory.” all over your source, you can construct an instance with a shorter name and call the methods through that.

Given valid arguments and valid data read from the input files, no method will throw an exception; but the read() overloads technically provide only the basic guarantee because, if something happens that causes read() to return a non-null error message, that method might have added objects to either or both of the collections passed as arguments.

Note:  in this version, since the input files are created manually, invalid data in input files is considered a contract violation. This might change in future releases; and that change might involve the read() methods throwing an exception.

The AmtrakHistory class is in the atkhist package.


The input files

When rows from the HTML table are copied and pasted into a plain text file, they look like:
12/31/2016 (Sa) 01/01/2017 9:45 AM (Su) 11:06AM Ar: 1 hr, 21 min late.
12/11/2017 (Mo) 12/12/2017 1:12 AM (Tu) 1:05AM Ar: 7 min early. SD
Breaking down the above examples
Origin Scheduled Actual
Time
Comments
Date Day Date Time Day
12/31/2016 (Sa) 01/01/2017 9:45 AM (Su) 11:06AM Ar: 1 hr, 21 min late.
12/11/2017 (Mo) 12/12/2017 1:12 AM (Tu) 1:05AM Ar: 7 min early. SD

This class doesn’t care about the date of origin; but the rest of the fields are interesting.

If the scheduled time is late at night, it’s possible that the actual time will be in the wee hours of the following morning. Similarly, if the scheduled time is early in the morning, it’s possible that the actual time will be late the previous day.

It’s also possible that the actual time doesn’t appear at all. This can happen if the train was annulled for some reason, or if the actual time just wasn’t reported because somebody at Amtrak forgot.

For trains that run less than daily, users will probably want to add an additional first line that contains a list of weekdays that the train runs. For example. train 50 departs Chicago only on Tuesday, Thursday and Saturday; so the first line of the file for that train and station would be “TuThSa”. The weekdays may appear in any order and may be in any combination of upper and lower case; but they must be the usual two-character abbreviations (the first two characters of the English name of the day).

Files for daily trains may begin with “MoTuWeThFrSaSu”, but that silliness isn’t required: they can begin with a blank line or with a regular data line (no additional first line at all).


The Traintime class

public static class Traintime implements Comparable<Traintime>
{
    public String date();
    public int weekday();
    public int time();
    public int late();

    @Override public int compareTo(Traintime);
    @Override public boolean equals(Object);
    @Override public int hashCode();

    public void backOneDay();
}
An instance of this inner class is constructed for each data line read from the input file. These objects are constructed only by AmtrakHistory.read(), so there’s no publicly visible constructor.

Note that there’s no train number or station code. AmtrakHistory.read() reads only data for a particular train and station. Programs that read data for more than one train and/or more than one station will presumably have separate collections of Traintimes for each train/station pair anyway.

date() returns the scheduled date in "yyyymmdd" form.

weekday() returns the scheduled day of the week, 1 for Monday through 7 for Sunday.

time() returns the actual time as minutes after midnight. This value can be ONE_DAY or more if the actual arrival or departure is on the day after the scheduled day. If the actual time is unknown for some reason (for example, the train didn’t actually run on the scheduled day, or maybe the time just never got reported), this method will return NO_TIME.

<warning>
time()
never returns a value less than zero. In the unlikely event that a train is sufficiently early that it arrives on the day before it’s scheduled, date() and weekday() will return the actual arrival day. This should affect arrivals only:  although there are cases when Amtrak trains depart before the scheduled time, the author is unaware of any examples of that happening around midnight.
</warning>

late() returns the number minutes the train is late (that is, the actual time minus the scheduled time). This will be negative if the train is early. If time() returns NO_TIME, this method will, too.

<warning>
If a train is a whole day late or more, and if the time is actually reported, this class will do the wrong thing. The author is unaware of any example of a train that late not having a “service disruption” and thus no time being reported.
</warning>

The usual Java comparisons are provided. Since a given train calls on a given station only once per day, the date is the only datum compared. (One exception to that general rule is that the Silver Star calls on Lakeland, FL twice in each direction; but Amtrak uses different three-character station codes for the two stops; and it’s the station code, not the physical place, that matters.)

On rare occasions, we might want an arrival time to be on the day after the day before (yes, really). For example, Pacific Surfliner train 796 arrives in San Diego at 01:06 on the day after it departs from Los Angeles; and the date that we care about might be the departure date. The backOneDay() method is provided to subtract one day from the date and the weekday, and to add ONE_DAY to the time. (Note that this adjustment had better be made before comparing the objects for any reason, including sorting them, storing them in sets, or using them as map keys.) It’s entirely up to the caller to determine whether to use this feature. No code described herein ever calls that method.


The read() methods

    public static String read(String filename,
                              Collection<Traintime> results,
                              Collection<Integer> weekdays);

    public static String read(String filename, Collection<Traintime> results)
    {
        // as if:
        return read(filename, results, null);
    }
This method reads the file named filename, constructs a Traintime for each regular input line, and adds those Traintimes to results. It returns an error message in the event of some I/O failure, or it returns a null pointer if the read completes successfully.

If weekdays is not null and the input file’s first line is a list of weekdays that the train runs, weekdays will have the specified weekdays (1 for Monday through 7 for Sunday) added to it. If weekdays is not null and the input file begins with a blank line or a regular data line (indicating a daily train), all seven possible values will be added.

Note that weekdays can be null, even for non-daily trains, if the caller doesn’t care. If it is null, and if the first line is a list of weekdays or a blank line, that first line will be ignored.

The class provides overloads that generate filenames for you:

    public static String read(boolean is_departure,
                              String train_number,
                              String station_code,
                              Collection<Traintime>);

    public static String read(boolean is_departure,
                              String train_number,
                              String station_code,
                              Collection<Traintime>,
                              Collection<Integer>);
The first argument indicates whether we’re looking for arrivals (false) or departures (true); the second argument is the train number; and the third argument is Amtrak’s three-character station code.

These methods generate filenames that begin with "ar" for arrivals or "dp" for departures followed by the train number, the station code, and the extension, ".txt". For example, data for train 50 departing Chicago would be expected to be in a file named "dp50chi.txt". The station code may be in any combination of upper or lower case. The generated filename will always be lower case.

There are also overloads that let you specify the train number as an int:

    public static String read(boolean is_departure,
                              int train_number,
                              String station_code,
                              Collection<Traintime>);

    public static String read(boolean is_departure,
                              int train_number,
                              String station_code,
                              Collection<Traintime>,
                              Collection<Integer>);


Public fields

//
// For when a departure or arrival time is unknown for some reason:
//
public static final int NO_TIME = Integer.MAX_VALUE;

//
// The number of minutes in a day:
//
public static final int ONE_DAY = 24 * 60;

//
// The days of the week:
//
public static final int BAD_DAY = 0;
public static final int MO = 1;
public static final int TU = 2;
public static final int WE = 3;
public static final int TH = 4;
public static final int FR = 5;
public static final int SA = 6;
public static final int SU = 7;
public static final int ALL_DAYS = 8;

public static final String[] WEEKDAY_ABBRV =
{
    "??", "Mo", "Tu", "We", "Th", "Fr", "Sa", "Su", "??"
};

public static final String[] WEEKDAY_NAMES =
{
    "No such day",
    "Monday", "Tuesday", "Wednesday", "Thursday",
    "Friday", "Saturday", "Sunday",
    "All Days" // extra added attraction
};


Make weekday values from character representations

    public static int makeWeekday(char first_char, char second_char);
This method returns one of MO through SU given the first two characters of the weekday name; or it returns BAD_DAY if either or both of the arguments are invalid. The arguments may be upper or lower case.

You might prefer to use String arguments:

    public static int makeWeekday(String s)
    {
        // as if:
        return makeWeekday(s.charAt(0), s.charAt(1));
    }
    public static int makeWeekday(String s, int pos)
    {
        // as if:
        return makeWeekday(s.charAt(pos), s.charAt(pos + 1));
    }


Readable dates

    public static String readableDate(String);
The Traintime class stores dates internally as "yyyymmdd" for lexicographical comparisons. This method simply adds hyphens for easier reading by H. sapiens. (The dates remain in ISO 8601 format. Localization is not anticipated at this time.)


Readable times

    public static String readableTime(int minutes, String minus_sign);
The Traintime class stores times of day internally as ints containing minutes after midnight. This method returns "hh:mm" strings for H. sapiens’ benefit. (The separator is the colon as specified in ISO 8601. Localization is not anticipated at this time.)

If minutes is NO_TIME, this method returns "?".

The first argument is allowed to be ONE_DAY or more. If it is, the returned value will still be in the range, "00:00" to "23:59", but with "+n" appended where n is the number of days. This is common practice in the travel industry.

The first argument might represent the difference between two times, for example, the amount of time that a train is early or late. If minutes is less than zero, the returned string will begin with the minus sign passed as the second argument; and the "hh:mm" part of the string will represent the absolute value. (Note that this is not “a time of day on the previous day”…that would be ONE_DAY minus the absolute value, not the absolute value itself.)

There are overloads that default minus_sign to "-" (the hyphen) and "&minus;" (the HTML entity):

    public static String readableTime(int minutes)
    {
        // as if:
        return readableTime(minutes, "-");
    }
    public static String htmlTime(int minutes)
    {
        // as if:
        return readableTime(minutes, "&minus;");
    }


Appendix A:  the actual source code

//
// AmtrakHistory.java
//
// Bill Seymour, 2018-02-03
//
// Copyright Bill Seymour 2018.
// Distributed under the Boost Software License, Version 1.0.
// (See accompanying file LICENSE_1_0.txt or copy at
// http://www.boost.org/LICENSE_1_0.txt)
//
// This class provides a mechanism for reading Amtrak departure
// and arrival times from files created from data received at
// https://juckins.net/amtrak_status/archive/html/history.php.
//
// See AmtrakHistory.html for user documentation.
//

package atkhist;

import java.util.Collection;
import java.io.BufferedReader;
import java.io.FileReader;

public class AmtrakHistory
{
    //
    // When the time is unknown:
    //
    public static final int NO_TIME = Integer.MAX_VALUE;

    //
    // The number of minutes in a day:
    //
    public static final int ONE_DAY = 24 * 60;

    //
    // Weekday values:
    //
    public static final int BAD_DAY = 0;
    public static final int MO = 1; // 1 to 7 common in travel industry
    public static final int TU = 2;
    public static final int WE = 3;
    public static final int TH = 4;
    public static final int FR = 5;
    public static final int SA = 6;
    public static final int SU = 7;
    public static final int ALL_DAYS = 8;

    public static final String[] WEEKDAY_ABBRV =
    {
        "??", "Mo", "Tu", "We", "Th", "Fr", "Sa", "Su", "??"
    };
    public static final String[] WEEKDAY_NAMES =
    {
        "No such day", "Monday", "Tuesday", "Wednesday",
        "Thursday", "Friday", "Saturday", "Sunday",
        "All Days" // extra added attraction
    };

    //
    // Convert weekday characters to ints:
    //
    public static int makeWeekday(char c0, char c1)
    {
        c1 = Character.toLowerCase(c1);

        switch (Character.toLowerCase(c0))
        {
            case 'm':
                if (c1 == 'o')
                    return MO;
                break;
            case 't':
                if (c1 == 'u')
                    return TU;
                if (c1 == 'h')
                    return TH;
                break;
            case 'w':
                if (c1 == 'e')
                    return WE;
                break;
            case 'f':
                if (c1 == 'r')
                    return FR;
                break;
            case 's':
                if (c1 == 'a')
                    return SA;
                if (c1 == 'u')
                    return SU;
                break;
        }
        return BAD_DAY;
    }
    public static int makeWeekday(String s, int pos)
    {
        return s != null && s.length() >= pos + 2 ?
            makeWeekday(s.charAt(pos), s.charAt(pos + 1)) : BAD_DAY;
    }
    public static int makeWeekday(String s)
    {
        return makeWeekday(s, 0);
    }

    //
    // Return a date string with hyphens added:
    //
    public static String readableDate(String dt)
    {
        StringBuilder val = new StringBuilder(10);
        val.append(dt.substring(0, 4));
        val.append('-');
        val.append(dt.charAt(4));
        val.append(dt.charAt(5));
        val.append('-');
        val.append(dt.charAt(6));
        val.append(dt.charAt(7));
        return val.toString();
    }

    //
    // Convert minutes after midnight to a String:
    //
    // Return "?" if the time is NO_TIME, else return "hh:mm".
    //
    // If the time is less than zero, e.g., a difference between two times,
    // the returned string will begin with the minus sign passed as the
    // second argument.  There are overloads that default the minus sign
    // to "-" (the hyphen) and "&minus;" (the HTML entity).
    //
    // If the time is 24 hours or more, the returned string will still be
    // in the range, "00:00" to "23:59", but with "+1" appended to indicate
    // the next day.
    //
    public static String readableTime(int mins, String minus)
    {
        if (mins != NO_TIME)
        {
            StringBuilder val = new StringBuilder(14);

            if (mins < 0)
            {
                mins = -mins;
                val.append(minus);
            }

            int hrs = mins / 60;
            mins %= 60;

            int dayCount = 0;
            while (hrs >= 24) // probably no more than once,
            {                 // but just in case ...
                hrs -= 24;
                ++dayCount;
            }

            if (hrs < 10)
                val.append('0');
            val.append(hrs);

            val.append(':');

            if (mins < 10)
                val.append('0');
            val.append(mins);

            if (dayCount != 0)
            {
                val.append('+');
                val.append(dayCount);
            }

            return val.toString();
        }
        return "?";
    }
    public static String readableTime(int mins)
    {
        return readableTime(mins, "-");
    }
    public static String htmlTime(int mins)
    {
        return readableTime(mins, "&minus;");
    }

    //
    // What we read from each input line:
    //
    // Note that the train number is not stored in the object since
    // we're reading just one train in each AmtrakHistory.read() call,
    // and programs that read more than one train will presumably have
    // separate collections of Traintimes for each train anyway.
    //
    public static class Traintime implements Comparable<Traintime>
    {
        private String dt;
        private int wd, tm, lt;

        /*
         * Input line examples:
         *
         * origin date |   expected day/time   | actual time
        12/31/2016 (Sa) 01/01/2017 9:45 AM (Su) 11:06AM Ar: 1 hr, 21 min late.
        12/11/2017 (Mo) 12/12/2017 1:12 AM (Tu) 1:05AM Ar: 7 min early. SD
         *
         * Note variations in time representation.  Except for DT_POS,
         * the following aren't fixed character positions, but rather
         * positions where we start searching.
         */
        private static final int DT_POS = 16;
        private static final int EX_POS = 27; // expected time
        private static final int DY_POS = 35;
        private static final int TM_POS = 40; // actual time

        //
        // We might sometimes need to subtract one from the date:
        //
        private static final int MDAY[/*is leap year*/][/*month (1-12)*/] =
        {
            { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 },
            { 0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 },
        };
        private void prevDate()
        {
            if (--wd < MO)
                wd = SU;

            int yr = Integer.parseInt(dt.substring(0, 4));
            int mo = Integer.parseInt(dt.substring(4, 6));
            int dy = Integer.parseInt(dt.substring(6, 8));

            if (--dy == 0)
            {
                if (--mo == 0)
                {
                    mo = 12;
                    --yr;
                }
                boolean leap = yr % 4 == 0 && (yr % 100 != 0 || yr % 400 == 0);
                dy = MDAY[leap ? 1 : 0][mo];
            }

            StringBuilder val = new StringBuilder(8);
            val.append(yr);
            if (mo < 10)
                val.append('0');
            val.append(mo);
            if (dy < 10)
                val.append('0');
            val.append(dy);

            dt = val.toString();
        }

        //
        // Ctor helpers:
        //
        private void initDate(String in)
        {
            // input date is mm/dd/yyyy
            StringBuilder val = new StringBuilder(8);
            val.append(in.charAt(DT_POS + 6));
            val.append(in.charAt(DT_POS + 7));
            val.append(in.charAt(DT_POS + 8));
            val.append(in.charAt(DT_POS + 9));
            val.append(in.charAt(DT_POS + 0));
            val.append(in.charAt(DT_POS + 1));
            val.append(in.charAt(DT_POS + 3));
            val.append(in.charAt(DT_POS + 4));
            dt = val.toString();
        }
        private void initWday(String in)
        {
            int pos = in.indexOf('(', DY_POS);
            wd = pos > 0 ?
                    makeWeekday(in.charAt(pos + 1), in.charAt(pos + 2)) :
                    BAD_DAY;
        }
        private static int parseTime(String in, int hrsPos)
        {
            // precondition:  hrsPos is the 1st decimal digit's position

            int colon = in.indexOf(':', hrsPos);
            if (colon != -1 && colon <= hrsPos + 2 && in.length() > colon + 4)
            {
                int hr = Integer.parseInt(in.substring(hrsPos, colon));
                int mn = Integer.parseInt(in.substring(colon + 1, colon + 3));

                int pm = in.indexOf("PM", colon + 3);
                if (pm != -1 && pm <= colon + 4) // got PM && it's the right one
                {
                    if (hr != 12)
                    {
                        hr += 12;
                    }
                }
                else if (hr == 12)
                {
                    hr = 0;
                }

                return hr * 60 + mn;
            }
            return NO_TIME;
        }
        private static int makeTime(String in, int pos)
        {
            int end = pos + 2; // gotta find a time very soon
            if (in.length() <= end)
                end = -1; // definitely won't find it

            while (pos < end && !Character.isDigit(in.charAt(pos)))
            {
                ++pos;
            }

            return pos < end ? parseTime(in, pos) : NO_TIME;
        }
        private boolean isEarly(String in)
        {
            int barPos = in.indexOf('|', TM_POS);
            if (barPos != -1)
            {
                //
                // If the comment field contains '|', then both arrival
                // and departure statuses are reported; and we don't know
                // /a priori/ which we want.  The best we can do is default
                // to the departure status if that matches expectations,
                // otherwise report the arrival status.  (Note that "On time"
                // can't be what we're looking for.)
                //
                if (in.indexOf("On", barPos) == -1) // departure not on-time
                {
                    int latePos = in.indexOf("late", barPos);
                    if (in.indexOf("On", TM_POS) != -1) // on-time arrival
                    {
                        return latePos == -1;
                    }

                    // neither arrival nor departure on-time
                    if (lt > 0 && latePos != -1)
                    {
                        return false; // late matches expections
                    }
                    if (lt < 0 && latePos == -1)
                    {
                        return true; // early matches expections
                    }
                }
                // no match (including on-time departure)
            }
            // only one status reported or no departure match

            int latePos = in.indexOf("late", TM_POS);

            // return no "late" at all || got a '|' && "late" after it
            return latePos == -1 || barPos != -1 && latePos > barPos;
        }
        private void initTime(String in)
        {
            if ((lt = tm = makeTime(in, TM_POS)) == NO_TIME)
                return; // unknown...nothing more to do

            if ((lt -= makeTime(in, EX_POS)) == 0)
                return; // on-time...nothing more to do

            boolean early = isEarly(in);
            if (lt < 0 && !early) // morning of next day
            {
                tm += ONE_DAY;
                lt += ONE_DAY;
            }
            else if (lt > 0 && early) // late night yesterday
            {
                prevDate();
                // tm correct for yesterday
                lt -= ONE_DAY;
            }
        }

        //
        // The ctor is private because these objects are constructed
        // only by AmtrakHistory.read().
        //
        private Traintime(String in)
        {
            initDate(in);
            initWday(in);
            initTime(in);
        }

        //
        // Accessors:
        //
        public String date() { return dt; }
        public int weekday() { return wd; }
        public int time()    { return tm; }
        public int late()    { return lt; }

        //
        // A given train calls on a given station only once per day,
        // so a Traintime can be uniquely identified by its date.
        //
        @Override public int compareTo(Traintime rhs)
        {
            return dt.compareTo(rhs.dt);
        }
        @Override public boolean equals(Object o)
        {
            return o instanceof Traintime && dt.equals(((Traintime)o).dt);
        }
        @Override public int hashCode()
        {
            return dt.hashCode();
        }

        //
        // On rare occasions, we might want an arrival time to be on the day
        // after the day before (yes, really).  For example, Pacific Surfliner
        // train 796 arrives in San Diego at 01:06 on the day after it departs
        // from Los Angeles; and the date that we care about might be the
        // departure date.
        //
        // (Note that this adjustment had better be made before comparing
        // the objects for any reason, including sorting them, storing them
        // in sets, or using them as map keys.)
        //
        public void backOneDay()
        {
            prevDate();
            tm += ONE_DAY;
            lt += ONE_DAY;
        }
    } // inner class Traintime

    //
    // Helpers for read():
    //
    private static void includeAll(Collection<Integer> wdys)
    {
        for (int i = MO; i <= SU; ++i)
            wdys.add(i);
    }
    private static boolean include(String in, Collection<Integer> wdys)
    {
        // precondition:  in.length() is even
        for (int i = 0; i < in.length(); i += 2)
        {
            int wday = makeWeekday(in.charAt(i), in.charAt(i + 1));
            if (wday == BAD_DAY)
                return false;
            wdys.add(wday);
        }
        return true;
    }

    //
    // The class' actual reason for being:
    //
    public static String read(String fn,
                              Collection<Traintime> dest,
                              Collection<Integer> wdys)
    {
        BufferedReader in = null;

        try
        {
            in = new BufferedReader(new FileReader(fn));

            if (!in.ready())
            {
                throw new Exception("Empty input file");
            }

            String s = in.readLine();

            if (wdys != null) // we care about scheduled weekdays
            {
                if (s.isEmpty()) // OK...train runs daily
                {
                    includeAll(wdys);
                }
                else if (Character.isDigit(s.charAt(0))) // normal input line,
                {                                        // train runs daily
                    includeAll(wdys);
                    dest.add(new Traintime(s));
                }
                else
                {
                    // 1st line should be list of scheduled weekdays
                    if (s.length() % 2 != 0 || !include(s, wdys))
                    {
                        throw new Exception("Invalid first line");
                    }
                }
            }
            else if (!s.isEmpty() && Character.isDigit(s.charAt(0)))
            {
                dest.add(new Traintime(s));
            }

            while (in.ready())
            {
                dest.add(new Traintime(in.readLine()));
            }

            return null; // success
        }
        catch (Exception ex)
        {
            return "Error reading " + fn + ":  " + ex.toString();
        }
        finally
        {
            if (in != null) { try { in.close(); } catch (Throwable t) { ; } }
        }
    }

    //
    // Overloads that make the filename for you:
    //
    public static String read(boolean dep,
                              String tn,
                              String sta,
                              Collection<Traintime> dest,
                              Collection<Integer> wdys)
    {
        StringBuilder fn = new StringBuilder(12);
        fn.append(dep ? "dp" : "ar");
        fn.append(tn);
        fn.append(sta.toLowerCase());
        fn.append(".txt");

        return read(fn.toString(), dest, wdys);
    }
    public static String read(boolean dep,
                              int tn,
                              String sta,
                              Collection<Traintime> dest,
                              Collection<Integer> wdys)
    {
        return read(dep, Integer.toString(tn), sta, dest, wdys);
    }

    //
    // Defaulting the included weekdays to null:
    //
    public static String read(String fn, Collection<Traintime> dest)
    {
        return read(fn, dest, null);
    }
    public static String read(boolean dep,
                              String tn,
                              String sta,
                              Collection<Traintime> dest)
    {
        return read(dep, tn, sta, dest, null);
    }
    public static String read(boolean dep,
                              int tn,
                              String sta,
                              Collection<Traintime> dest)
    {
        return read(dep, tn, sta, dest, null);
    }
}

// End of AmtrakHistory.java


Appendix B:  a sample application

//
// SanTrip.java
//
// Bill Seymour, 2018-02-03
//
// Copyright Bill Seymour 2018.
// Distributed under the Boost Software License, Version 1.0.
// (See accompanying file LICENSE_1_0.txt or copy at
// http://www.boost.org/LICENSE_1_0.txt)
//
// If I take the Texas Eagle to Los Angeles arriving on a Monday,
// then take one of the Pacific Surfliners to San Diego, how much
// of the first day of my meeting will I miss?
//
// This program illustrates the use of the AmtrakHistory class.
// Note that I don't care about the likelihood of making
// a connection since there are several Surfliners and I'll
// certainly connect to one of them (unless the Eagle is
// annulled for some reason).  The question is which Surfliner
// I'd connect to and how many times I actually arrive in San Diego
// before some particular point in the meeting.
//
// Observed output for 53 Monday arrivals 2017-01-02 through 2018-01-01:
/*
09:00:   8 times (15%) - a few minutes late
10:30:  37 times (70%) - in time for morning refreshments
12:00:   7 times (13%) - in time for lunch
15:00:   1 time  ( 2%) - in time for afternoon refreshments
 */

import atkhist.AmtrakHistory;

import java.util.Map;
import java.util.TreeMap;
import java.util.ArrayList;
import java.util.Collections;

public final class SanTrip
{
    private static final int NEVER = AmtrakHistory.NO_TIME;

    //
    // The minimum number of minutes to make a connection:
    //
    private static final int MIN_LAYOVER = 5;

    //
    // All the Surfliner train numbers that we care about:
    //
    private static final int SURFNOS[] =
    {
        562, 564, 566, 568, 572, 580, 582, 584, 590, 592, // orig. LAX
        768, 774, 782, 784, 790, 792, 796, // orig. north of LAX
        1566, 1588, // a couple of holiday trains
    };

    //
    // The number of times that various San Diego arrivals happen:
    //
    private static class Count
    {
        int tm;     // minutes after midnight
        int cnt;    // number of times it happens
        String stm; // human-readable time
        String msg;

        Count(int t, int c, String s, String m)
        {
            tm = t;
            cnt = c;
            stm = s;
            msg = m;
        }
    }
    private static final Count CNTS[] =
    {
      //
      // We hope that the venue will be easy walking distance
      // from the Santa Fe depot; and we won't have checked
      // baggage to wait for.
      //
        new Count(  525, 0, "08:45", "make the whole meeting"),
        new Count(  540, 0, "09:00", "a few minutes late"),
        new Count(  630, 0, "10:30", "in time for morning refreshments"),
        new Count(  720, 0, "12:00", "in time for lunch"),
        new Count(  810, 0, "13:30", "in time for afternoon session"),
        new Count(  900, 0, "15:00", "in time for afternoon refreshments"),
        new Count( 1560, 0, "Later", "miss the whole day"),
        new Count(NEVER, 0, "Never", "drop fifteen and punt"),
    };

    //
    // Since we need to keep Surfliner departure and arrival times together,
    // and since AmtrakHistory.read() will read only one at a time, we'll
    // also need to keep track of the train number so that we can know
    // which departure time belongs with which arrival time.
    //
    private static final class Surf implements Comparable<Surf>
    {
        int tr, dp, ar;

        void assignTime(boolean dep, int tm)
        {
            if (dep)
                dp = tm;
            else
                ar = tm;
        }

        Surf(boolean dep, int t, int tm)
        {
            tr = t;
            assignTime(dep, tm);
            assignTime(!dep, NEVER);
        }

        //
        // for sorting by departure time:
        //
        @Override public int compareTo(Surf rhs)
        {
            return dp - rhs.dp;
        }
        @Override public boolean equals(Object o)
        {
            return o instanceof Surf && dp == ((Surf)o).dp;
        }
        @Override public int hashCode()
        {
            return dp;
        }
    }

    private static TreeMap<String/*date*/,ArrayList<Surf>> surfTrains =
        new TreeMap<String,ArrayList<Surf>>();

    private static void addSurf(boolean dp, int tr, AmtrakHistory.Traintime tt)
    {
        if (!dp && tr == 796)
        {
            //
            // We have meta-knowledge that train 796 runs overnight,
            // and that the input files for arrivals will report
            // the arrival date; but it's the departure date that
            // we care about.
            //
            tt.backOneDay();
        }

        if (tt.weekday() != AmtrakHistory.MO)
            return; // don't bother if it's not Monday

        ArrayList<Surf> surfs = surfTrains.get(tt.date());

        if (surfs == null) // don't have this date yet
        {
            surfs = new ArrayList<Surf>();
            surfs.add(new Surf(dp, tr, tt.time()));
            surfTrains.put(tt.date(), surfs);
            return;
        }

        //
        // We've got the date.  Do we have this train yet?
        //
        for (Surf sf : surfs)
        {
            if (tr == sf.tr)
            {
                sf.assignTime(dp, tt.time());
                return;
            }
        }

        //
        // Got the date but not the train yet:
        //
        surfs.add(new Surf(dp, tr, tt.time()));
    }

    private static void countTime(int tm)
    {
        for (Count c : CNTS)
        {
            if (tm <= c.tm)
            {
                // definitely found (c.tm == NEVER eventually)
                ++c.cnt;
                return;
            }
        }
    }

    private static boolean countConnect(int artm, ArrayList<Surf> sfs)
    {
        int dptm = artm + MIN_LAYOVER;
        for (Surf sf : sfs)
        {
            if (dptm <= sf.dp && sf.dp != NEVER)
            {
                countTime(sf.ar);
                return true;
            }
        }
        return false; // no connection at all
    }

    public static void main(String[] args)
    {
        ArrayList<AmtrakHistory.Traintime> eagles =
            new ArrayList<AmtrakHistory.Traintime>();

        ArrayList<AmtrakHistory.Traintime> surfliners =
            new ArrayList<AmtrakHistory.Traintime>();

        String err;

        //
        // The Texas Eagle input file will actually be for train 1
        // (the Sunset Limited), not train 421 (the Texas Eagle
        // through cars that were added in San Antonio); and we
        // don't care about the days of the week.
        //
        err = AmtrakHistory.read("ar1lax.txt", eagles);
        if (err != null)
        {
            System.err.println(err);
            System.exit(1);
        }

        for (int tr : SURFNOS)
        {
            //
            // Read the Surfliner departures:
            //
            surfliners.clear();
            err = AmtrakHistory.read(true, tr, "lax", surfliners);
            if (err != null)
            {
                System.err.println(err);
                System.exit(1);
            }
            for (AmtrakHistory.Traintime tt : surfliners)
            {
                addSurf(true, tr, tt);
            }

            //
            // Read the Surfliner arrivals:
            //
            surfliners.clear();
            err = AmtrakHistory.read(false, tr, "san", surfliners);
            if (err != null)
            {
                System.err.println(err);
                System.exit(1);
            }
            for (AmtrakHistory.Traintime tt : surfliners)
            {
                addSurf(false, tr, tt);
            }
        }

        //
        // Assure that, for any given date, the surfliners are sorted
        // by departure time:
        //
        for (Map.Entry<String,ArrayList<Surf>> node : surfTrains.entrySet())
        {
            Collections.sort(node.getValue());
        }

        //
        // Count the arrival times:
        //
        for (AmtrakHistory.Traintime eagle : eagles)
        {
            if (eagle.time() == NEVER)
            {
                ++CNTS[CNTS.length - 1].cnt; // never arrived that day
            }
            else
            {
                ArrayList<Surf> surfs = surfTrains.get(eagle.date());

                if (surfs == null || !countConnect(eagle.time(), surfs))
                {
                    // no Surfliner at all that day || missed even the last one
                    ++CNTS[CNTS.length - 1].cnt;
                }
            }
        }

        //
        // Report the results:
        //
        int tot = eagles.size();
        System.out.println();
        for (Count ct : CNTS)
        {
            if (ct.cnt > 0)
            {
                System.out.print(ct.stm);
                System.out.print(": ");
                if (ct.cnt < 100)
                    System.out.print(' ');
                if (ct.cnt < 10)
                    System.out.print(' ');
                System.out.print(ct.cnt);
                System.out.print(" time");
                System.out.print(ct.cnt > 1 ? "s (" : "  (");
                int pct = (int)Math.round(ct.cnt * 100.0 / tot);
                if (pct < 10)
                    System.out.print(' ');
                System.out.print(pct);
                System.out.print("%) - ");
                System.out.println(ct.msg);
            }
        }
    }
}

// End of SanTrip.java


All suggestions and corrections will be welcome; all flames will be amusing.
Mail to was at pobox dot com