diff --git a/edosl0util/crgen.py b/edosl0util/crgen.py
new file mode 100644
index 0000000000000000000000000000000000000000..087af19c24056e06cf6fe7166a725cd78898e4ea
--- /dev/null
+++ b/edosl0util/crgen.py
@@ -0,0 +1,200 @@
+
+"""EDOS PDS construction record generation for SUOMI NPP"""
+
+from datetime import datetime
+import itertools
+import os
+from edosl0util.headers import DaySegmentedTimecode
+from edosl0util.stream import jpss_packet_stream
+
+def test_build_apid_info():
+    # FIXME: build CR comparison into the CLI
+    calculated = build_cr('P1571289CRISSCIENCEAAT15320210920101.PDS')
+    ingested = read_pds_cr('P1571289CRISSCIENCEAAT15320210920100.PDS')
+    insert_fake_cr_info(ingested)
+    del calculated['completion_time']  # it seems CR completion time does not match PDS
+    del ingested['completion_time']    # creation time from the file name
+    assert calculated == ingested
+
+def build_cr(pds_file, prev_pds_file=None):
+    """Best-effort CR generation by scanning a PDS data file
+
+    Previous PDS data file may also be given to make gap detection more complete.
+    """
+    def main():
+        scan = scan_packets(pds_file, prev_pds_file)
+        rv = {}
+        rv['pds_id'] = pds_id_from_path(pds_file)
+        rv['completion_time'] = get_pds_creation_time(pds_file)
+        rv['first_packet_time'] = scan['first_packet_time']
+        rv['last_packet_time'] = scan['last_packet_time']
+        rv['apid_info'] = build_apid_info(scan['apid_info'])
+        rv['apid_count'] = len(rv['apid_info'])
+        rv['file_info'] = file_info(rv['pds_id'], rv['apid_info'])
+        rv['file_count'] = len(rv['file_info'])
+        rv.update(aggregated_values(rv['apid_info']))
+        insert_fake_cr_info(rv)
+        return rv
+    def file_info(pds_id, apid_info):
+        cr_entry = {'file_name': pds_id + '.PDS', 'apid_count': 0, 'apid_info' : []}
+        data_entry = {'file_name': pds_file,
+                      'apid_count': len(apid_info),
+                      'apid_info': [{'scid': npp_scid, 'apid': entry['apid'],
+                                     'first_packet_time': entry['first_packet_time'],
+                                     'last_packet_time': entry['last_packet_time']}
+                                        for entry in apid_info]}
+        return [cr_entry, data_entry]
+    def aggregated_values(apid_info):
+        keys = ['total_packets', 'total_bytes', 'gap_count', 'fill_bytes',
+                'mismatched_length_packets', 'rs_corrected_packets']
+        return {key: sum(entry[key] for entry in apid_info) for key in keys}
+    return main()
+
+def insert_fake_cr_info(cr):
+    """Populate a CR with phony values for fields that can't be discovered via a packet scan"""
+    cr.update({'edos_sw_ver_major': 0, 'edos_sw_ver_minor': 0, 'cr_type': 1, 'test_flag': 0,
+               'scs_count': 1, 'scs_info': {'start': missing_time_value,
+                                             'stop': missing_time_value},
+               'first_packet_esh_time': missing_time_value,
+               'last_packet_esh_time': missing_time_value,
+               'fill_bytes': 0, 'mismatched_length_packets': 0, 'rs_corrected_packets': 0})
+    insert_fake_apid_info(cr['apid_info'])
+
+def pds_id_from_path(pds_file):
+    """Pull 36-char PDS ID from a file name; that's the CR file name minus the .PDS"""
+    file_name = os.path.basename(pds_file)
+    pds_file_name_length = 40
+    if len(file_name) != pds_file_name_length:
+        raise ValueError('PDS file name {} not of expected length {}'.format(
+                             file_name, pds_file_name_length))
+    return file_name[:34] + '00'
+
+def get_pds_creation_time(pds_file_or_id):
+    """Parse 11-char creation time out of a PDS ID or file name; return a DaySegmentedTimecode"""
+    return datetime_to_ccsds(datetime.strptime(pds_file_or_id[22:33], '%y%j%H%M%S'))
+
+def build_apid_info(scan_apid_info):
+    """Build up apid_info resulting from scan_packets into a full apid_info for a CR"""
+    for entry in scan_apid_info:
+        entry['scid'] = npp_scid
+        entry['vcid_count'] = 1
+        entry['vcid_info'] = [{'scid': npp_scid, 'vcid': npp_apid_to_vcid_map[entry['apid']]}]
+        entry['gap_count'] = len(entry['gap_info'])
+        del entry['last_packet_ssc']  # field was needed for bookkeeping but isn't in the CR
+    insert_fake_apid_info(apid_info)
+    return apid_info
+
+def insert_fake_apid_info(apid_info):
+    """Fill CR apid_info with phony values for fields that can't be found via a packet scan"""
+    for entry in apid_info:
+        entry.update({
+            'fill_packets': 0, 'fill_packet_info': [], 'fill_bytes': 0,
+            'mismatched_length_packets': 0, 'mismatched_length_packet_ssc_list': [],
+            'first_packet_esh_time': missing_time_value,
+            'last_packet_esh_time': missing_time_value,
+            'rs_corrected_packets': 0})
+        for gap in entry['gap_info']:
+            gap.update({'pre_gap_packet_esh_time': missing_time_value,
+                        'post_gap_packet_esh_time': missing_time_value})
+
+missing_time_value = DaySegmentedTimecode(0, 0, 0)
+
+npp_scid = 157  # FIXME: i guess this should come from the file name
+
+# ripped from MDFCB, 2014-06-05 revision
+# modified to place CrIS FOV 6 in VCID 7 after testing against some real data
+npp_vcid_to_apids_map = {
+    0: list(range(0, 15)) + list(range(16, 50)) + [65, 70, 100, 146, 155, 512, 513, 518,
+       543, 544, 545, 550, 768, 769, 773] + list(range(1280, 1289)),
+    1: [50, 101, 515, 528, 530, 531],
+    2: [102],
+    3: [103, 514, 536],
+    4: [104],
+    5: [105],
+    6: [106, 1289, 1290] + list(set(range(1315, 1396)) - set(range(1318, 1391, 9))
+                                    - set(range(1320, 1393, 9))),
+    7: [107] + list(range(1318, 1391, 9)) + list(range(1320, 1393, 9)),
+    8: [108, 1294, 1295, 1296, 1398],
+    9: [109],
+    10: [110],
+    11: [111, 560, 561, 564, 565, 576],
+    12: [112, 562, 563, 566],
+    13: [113, 546, 577, 578, 579, 580, 581, 582],
+    14: [114],
+    15: [115],
+    16: [116] + list(range(800, 806)) + list(range(807, 824)) + [825, 826],
+    17: [117, 806],
+    18: [118, 770] + list(range(830, 854)) + [855, 856],
+    19: [119],
+    20: [120],
+    21: [121, 517, 524, 549, 556, 780, 1291, 1292, 1293, 1397],
+    22: [122],
+    24: [147, 148, 149, 150]}
+npp_apid_to_vcid_map = {apid: vcid for vcid, apids in npp_vcid_to_apids_map.items()
+                                   for apid in apids}
+
+def scan_packets(pds_file, prev_pds_file=None):
+    """Scan a PDS data file for information needed to produce a construction record"""
+    def main():
+        prev_apid_map = build_prev_apid_map(prev_pds_file)
+        apid_map = {}
+        stream = jpss_packet_stream(open(pds_file, 'rb'))
+        first_pkt = stream.next()
+        for pkt in itertools.chain([first_pkt], stream):
+            entry = apid_map.get(pkt.apid)
+            if not entry:
+                entry_from_prev_pds = prev_apid_map.get(pkt.apid)
+                apid_map[pkt.apid] = init_entry(pkt, entry_from_prev_pds)
+            else:
+                update_entry(entry, pkt)
+        last_pkt = pkt
+        return {'first_packet_time': datetime_to_ccsds(first_pkt.stamp),
+                'last_packet_time': datetime_to_ccsds(last_pkt.stamp),
+                'apid_info': [apid_map[k] for k in sorted(apid_map)]}
+    def build_prev_apid_map(prev_pds_file):
+        if prev_pds_file:
+            return {entry['apid']: entry for entry in scan_packets(prev_pds_file)['apid_info']}
+        else:
+            return {}
+    def init_entry(pkt, entry_from_prev_pds):
+        rv = {'apid': pkt.apid,
+              'first_packet_time': datetime_to_ccsds(pkt.stamp), 'first_packet_offset': pkt.offset,
+              'last_packet_time': datetime_to_ccsds(pkt.stamp), 'last_packet_ssc': pkt.seqid,
+              'total_packets': 1, 'total_bytes': pkt.size, 'gap_info': []}
+        if entry_from_prev_pds:
+            update_gap_info(rv['gap_info'], entry_from_prev_pds['last_packet_ssc'],
+                            entry_from_prev_pds['last_packet_time'], pkt)
+        return rv
+    def update_entry(entry, new_pkt):
+        prev_last_ssc = entry['last_packet_ssc']
+        prev_last_time = entry['last_packet_time']
+        entry['last_packet_time'] = datetime_to_ccsds(new_pkt.stamp)
+        entry['last_packet_ssc'] = new_pkt.seqid
+        entry['total_packets'] += 1
+        entry['total_bytes'] += new_pkt.size
+        update_gap_info(entry['gap_info'], prev_last_ssc, prev_last_time, new_pkt)
+    def update_gap_info(gap_info, last_ssc, last_pkt_time, new_pkt):
+        ssc_limit = 16384  # one more than highest SSC
+        expected_new_ssc = (last_ssc + 1) % ssc_limit
+        if new_pkt.seqid != expected_new_ssc:
+            gap_entry = {'first_missing_ssc': expected_new_ssc,
+                         'missing_packet_count': (new_pkt.seqid - expected_new_ssc) % ssc_limit,
+                         'pre_gap_packet_time': last_pkt_time,
+                         'post_gap_packet_time': datetime_to_ccsds(new_pkt.stamp),
+                         'post_gap_packet_offset': new_pkt.offset}
+            gap_info.append(gap_entry)
+    return main()
+
+def datetime_to_ccsds(dt):
+    """Convert a packet stamp to DaySegmentedTimecode
+
+    Handles input of None by returning epoch value of 1958-01-01.
+    """
+    if dt is not None:
+        epoch = datetime(1958, 1, 1)
+        days = (dt - epoch).days
+        micros = int((dt - datetime(dt.year, dt.month, dt.day)).total_seconds() * 1e6)
+        return DaySegmentedTimecode(days, micros // 1000, micros % 1000)
+    else:
+        return DaySegmentedTimecode()
+
diff --git a/edosl0util/crio.py b/edosl0util/crio.py
new file mode 100644
index 0000000000000000000000000000000000000000..11e9439f5281bd329f067d7ce992801ea1fccda6
--- /dev/null
+++ b/edosl0util/crio.py
@@ -0,0 +1,186 @@
+
+"""PDS construction record input and output"""
+
+import ctypes as c
+import warnings
+from edosl0util.headers import BaseStruct, DaySegmentedTimecode
+
+def read(cr_file):
+    """Parse a PDS construction record from a file (*00.PDS)"""
+    def main():
+        rv = {}
+        with open(cr_file, 'rb') as f:
+            read_into_dict(f, Main1Struct, rv)
+            rv['scs_info'] = [read_struct(f, ScsStruct) for i in range(rv['scs_count'])]
+            read_into_dict(f, Main2Struct, rv)
+            rv['apid_info'] = []
+            for i in range(rv['apid_count']):
+                d = {}
+                read_into_dict(f, Apid1Struct, d)
+                d['vcid_info'] = [read_struct(f, ApidVcidStruct) for j in range(d['vcid_count'])]
+                read_into_dict(f, Apid2Struct, d)
+                d['gap_info'] = [read_struct(f, ApidGapStruct) for j in range(d['gap_count'])]
+                read_into_dict(f, Apid3Struct, d)
+                d['fill_packet_info'] = [
+                    read_struct(f, ApidFillStruct) for j in range(d['fill_packets'])]
+                read_into_dict(f, Apid4Struct, d)
+                d['mismatched_length_packet_ssc_list'] = [
+                    read_struct(f, ApidMismatchedLengthStruct)['packet_ssc']
+                        for i in range(d['mismatched_length_packets'])]
+                read_into_dict(f, Apid5Struct, d)
+                rv['apid_info'].append(d)
+            read_into_dict(f, Main3Struct, rv)
+            rv['file_info'] = []
+            for i in range(rv['file_count']):
+                d = {}
+                read_into_dict(f, FileStruct, d)
+                d['apid_info'] = [read_struct(f, FileApidStruct) for j in range(d['apid_count'])]
+                if d['apid_count'] == 0:  # bogus all-zero apid struct is present for the CR file
+                    read_struct(f, FileApidStruct)
+                rv['file_info'].append(d)
+            if f.read():
+                warnings.warn('{} bytes remain after reading CR'.format(len(extra)))
+            return rv
+    def read_into_dict(f, struct, data):
+        data.update(read_struct(f, struct))
+    def read_struct(f, struct):
+        rv = struct_to_dict(struct.from_buffer_copy(f.read(c.sizeof(struct))))
+        rv = {k: v for k, v in rv.items() if not k.startswith('spare_')}  # no spare fields
+        return {k: int(v) if isinstance(v, long) else v for k, v in rv.items()}  # no longs
+    return main()
+
+def write(cr, out_file):
+    """Write out a PDS construction record file"""
+    def main():
+        with open(out_file, 'wb') as f:
+            write_struct(cr, Main1Struct, f)
+            for d in cr['scs_info']:
+                write_struct(d, ScsStruct, f)
+            write_struct(cr, Main2Struct, f)
+            for d in cr['apid_info']:
+                write_struct(d, Apid1Struct, f)
+                for dd in d['vcid_info']:
+                    write_struct(dd, ApidVcidStruct, f)
+                write_struct(d, Apid2Struct, f)
+                for dd in d['gap_info']:
+                    write_struct(dd, ApidGapStruct, f)
+                write_struct(d, Apid3Struct, f)
+                for dd in d['fill_packet_info']:
+                    write_struct(dd, ApidFillStruct, f)
+                write_struct(d, Apid4Struct, f)
+                for ssc in d['mismatched_length_packet_ssc_list']:
+                    write_struct({'ssc': ssc}, ApidMismatchedLengthStruct, f)
+                write_struct(d, Apid5Struct, f)
+            write_struct(cr, Main3Struct, f)
+            for d in cr['file_info']:
+                write_struct(d, FileStruct, f)
+                for dd in d['apid_info']:
+                    write_struct(dd, FileApidStruct, f)
+                if d['apid_count'] == 0:
+                    write_struct({}, FileApidStruct, f)  # one all-zero apid struct if no others
+    def write_struct(data, struct, out):
+        fields = [f[0] for f in struct._fields_]
+        struct_data = {k: v for k, v in data.items() if k in fields}
+        out.write(memoryview(struct(**struct_data)))
+    main()
+
+def struct_to_dict(s):
+    return {f[0]: getattr(s, f[0]) for f in s._fields_}
+
+class Main1Struct(BaseStruct):
+    _fields_ = [('edos_sw_ver_major', c.c_uint8),
+                ('edos_sw_ver_minor', c.c_uint8),
+                ('cr_type', c.c_uint8),
+                ('spare_1', c.c_uint8 * 1),
+                ('pds_id', c.c_char * 36),
+                ('test_flag', c.c_uint8),
+                ('spare_2', c.c_uint8 * 9),
+                ('scs_count', c.c_uint16)]
+
+class ScsStruct(BaseStruct):
+    _fields_ = [('start', DaySegmentedTimecode),
+                ('stop', DaySegmentedTimecode)]
+
+class Main2Struct(BaseStruct):
+    _fields_ = [('fill_bytes', c.c_uint64),
+                ('mismatched_length_packets', c.c_uint32),
+                ('first_packet_time', DaySegmentedTimecode),
+                ('last_packet_time', DaySegmentedTimecode),
+                ('first_packet_esh_time', DaySegmentedTimecode),
+                ('last_packet_esh_time', DaySegmentedTimecode),
+                ('rs_corrected_packets', c.c_uint32),
+                ('total_packets', c.c_uint32),
+                ('total_bytes', c.c_uint64),
+                ('gap_count', c.c_uint32),
+                ('completion_time', DaySegmentedTimecode),
+                ('spare_3', c.c_uint8 * 7),
+                ('apid_count', c.c_uint8)]
+
+class Apid1Struct(BaseStruct):
+    _fields_ = [('spare_1', c.c_uint8 * 1),
+                ('scid', c.c_uint8),
+                ('apid', c.c_uint16),
+                ('first_packet_offset', c.c_uint64),
+                ('spare_2', c.c_uint8 * 3),
+                ('vcid_count', c.c_uint8)]
+
+class ApidVcidStruct(BaseStruct):
+    _fields_ = [('spare_1', c.c_uint16),
+                ('spare_2', c.c_uint16, 2),
+                ('scid', c.c_uint16, 8),
+                ('vcid', c.c_uint16, 6)]
+
+class Apid2Struct(BaseStruct):
+    _fields_ = [('gap_count', c.c_uint32)]
+
+class ApidGapStruct(BaseStruct):
+    _fields_ = [('first_missing_ssc', c.c_uint32),
+                ('post_gap_packet_offset', c.c_uint64),
+                ('missing_packet_count', c.c_uint32),
+                ('pre_gap_packet_time', DaySegmentedTimecode),
+                ('post_gap_packet_time', DaySegmentedTimecode),
+                ('pre_gap_packet_esh_time', DaySegmentedTimecode),
+                ('post_gap_packet_esh_time', DaySegmentedTimecode)]
+                
+class Apid3Struct(BaseStruct):
+    _fields_ = [('fill_packets', c.c_uint32)]
+
+class ApidFillStruct(BaseStruct):
+    _fields_ = [('packet_ssc', c.c_uint32),
+                ('packet_offset', c.c_uint64),
+                ('first_fill_byte', c.c_uint32)]
+
+class Apid4Struct(BaseStruct):
+    _fields_ = [('fill_bytes', c.c_uint64),
+                ('mismatched_length_packets', c.c_uint32)]
+
+class ApidMismatchedLengthStruct(BaseStruct):
+    _fields_ = [('packet_ssc', c.c_uint32)]
+
+class Apid5Struct(BaseStruct):
+    _fields_ = [('first_packet_time', DaySegmentedTimecode),
+                ('last_packet_time', DaySegmentedTimecode),
+                ('first_packet_esh_time', DaySegmentedTimecode),
+                ('last_packet_esh_time', DaySegmentedTimecode),
+                ('rs_corrected_packets', c.c_uint32),
+                ('total_packets', c.c_uint32),
+                ('total_bytes', c.c_uint64),
+                ('spare_3', c.c_uint64)]
+
+class Main3Struct(BaseStruct):
+    _fields_ = [('spare_4', c.c_uint8 * 3),
+                ('file_count', c.c_uint8)]
+
+class FileStruct(BaseStruct):
+    _fields_ = [('file_name', c.c_char * 40),
+                ('spare_1', c.c_uint8 * 3),
+                ('apid_count', c.c_uint8)]
+
+class FileApidStruct(BaseStruct):
+    _fields_ = [('spare_1', c.c_uint8 * 1),
+                ('scid', c.c_uint8),
+                ('apid', c.c_uint16),
+                ('first_packet_time', DaySegmentedTimecode),
+                ('last_packet_time', DaySegmentedTimecode),
+                ('spare_2', c.c_uint8 * 4)]
+
diff --git a/edosl0util/headers.py b/edosl0util/headers.py
index bb017f7fd12a7c66908805554852393e975e4551..2eb2ff8e55cffc461bbf6705ab097dc5dfcef112 100644
--- a/edosl0util/headers.py
+++ b/edosl0util/headers.py
@@ -37,6 +37,9 @@ class BaseStruct(c.BigEndianStructure):
         fields = ', '.join('%s=%s' % (f[0], repr(getattr(self, f[0]))) for f in self._fields_)
         return '<%s (%s)>' % (self.__class__.__name__, fields)
 
+    def __eq__(self, other):
+        return all(getattr(self, f[0]) == getattr(other, f[0]) for f in self._fields_)
+
 
 class PrimaryHeader(BaseStruct):
     """