Repository: Smithsonian/layup
Pull request #213: Unpack verb
Author: login: Little-Ryugu
Created: 2025-05-02T22:52:00Z | Merged: 2025-05-14T15:52:49Z

Fixes #206  .
made unpack verb that replaces covarience matrix with uncertainties for each orbital parameter

## Review Checklist for Source Code Changes

- [x] Does pip install still work?
- [x] Have you written a unit test for any new functions?
- [x] Do all the units tests run successfully?
- [x] Does Layup run successfully on a test set of input files/databases?
- [x] Have you used black on the files you have updated to confirm python programming style guide enforcement?


Linked issue #206: make layup upack
get the orbital uncertainties in the various elements in terms of a+/-da, etc
Linked issue comments: user: login: mschwamb
created_at: 2025-05-02T17:34:53Z
body: unpack arguments are really simple filename and output file stem 
author_association: COLLABORATOR

Diff:
diff --git a/pyproject.toml b/pyproject.toml
index b4060b7e..28b46a8b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,6 +36,7 @@ layup-init = "layup_cmdline.init:main"
 layup-convert = "layup_cmdline.convert:main"
 layup-comet = "layup_cmdline.comet:main"
 layup-demo = "layup_cmdline.demo:main"
+layup-unpack = "layup_cmdline.unpack:main"
 
 [project.urls]
 "Source Code" = "https://github.com/Smithsonian/layup"
diff --git a/src/layup/unpack.py b/src/layup/unpack.py
new file mode 100644
index 00000000..a89fddc7
--- /dev/null
+++ b/src/layup/unpack.py
@@ -0,0 +1,172 @@
+import numpy as np
+import logging
+from pathlib import Path
+
+from layup.utilities.data_processing_utilities import parse_fit_result
+from layup.utilities.file_io import CSVDataReader, HDF5DataReader
+from layup.utilities.file_io.file_output import write_csv, write_hdf5
+
+logger = logging.getLogger(__name__)
+
+
+INPUT_READERS = {
+    "csv": CSVDataReader,
+    "hdf5": HDF5DataReader,
+}
+
+
+def _get_result_dtypes(primary_id_column_name: str, state: list, sigma: list):
+    """Helper function to create the result dtype with the correct primary ID column name."""
+    # Define a structured dtype for format of unpacked output file
+
+    return np.dtype(
+        [
+            (primary_id_column_name, "O"),  # Object ID
+            ("csq", "f8"),  # Chi-square value
+            ("ndof", "i4"),  # Number of degrees of freedom
+            (state[0], "f8"),  # The first of 6 state vector elements
+            (sigma[0], "f8"),  # The first of 6 sigma vector elements
+            (state[1], "f8"),
+            (sigma[1], "f8"),
+            (state[2], "f8"),
+            (sigma[2], "f8"),
+            (state[3], "f8"),
+            (sigma[3], "f8"),
+            (state[4], "f8"),
+            (sigma[4], "f8"),
+            (state[5], "f8"),  # The last of 6 state vector elements
+            (sigma[5], "f8"),  # The last of 6 state vector elements
+            ("epochMJD_TDB", "f8"),  # Epoch
+            ("niter", "i4"),  # Number of iterations
+            ("method", "O"),  # Method used for orbit fitting
+            ("flag", "i4"),  # Single-character flag indicating success of the fit
+            ("FORMAT", "O"),  # Orbit format
+        ]
+    )
+
+
+def unpack(res, name, orbit_para, _RESULT_DTYPES):
+    """
+    unpacks a file containing a covarience matrix into assoicated uncertainties.
+    e.g. name, values (x6), covariance (6x6) ---> name, value_i, sigma_value_i, ....
+
+    """
+
+    j = [0, 7, 14, 21, 28, 35]  # location of cov_ii [00,11,22,33,44,55]
+    error_list = []
+    for i, orbit_para in enumerate(orbit_para):
+        error_list.append(np.sqrt(res.cov[j[i]]))
+
+    output = np.array(
+        [
+            (
+                name,
+                res.csq,
+                res.ndof,
+            )
+            + (
+                res.state[0],
+                error_list[0],
+                res.state[1],
+                error_list[1],
+                res.state[2],
+                error_list[2],
+                res.state[3],
+                error_list[3],
+                res.state[4],
+                error_list[4],
+                res.state[5],
+                error_list[5],
+            )
+            + (
+                res.epoch - 2400000.5,  # changing from jd back to mjd
+                res.niter,
+                res.method,
+                res.flag,
+                "BCART",  # The base format returned by the C++ code
+            )
+        ],
+        dtype=_RESULT_DTYPES,
+    )
+    return output
+
+
+def unpack_cli(
+    input,
+    file_format,
+    output_file_stem,
+):
+
+    primary_id_column_name = "provID"
+
+    input_file = Path(input)
+    if file_format == "csv":
+        output_file = Path(f"{output_file_stem}.{file_format.lower()}")
+    else:
+        output_file = (
+            Path(f"{output_file_stem}")
+            if output_file_stem.endswith(".h5")
+            else Path(f"{output_file_stem}.h5")
+        )
+
+    # Open the input file and read the first line
+    reader_class = INPUT_READERS.get(file_format)
+
+    sample_reader = reader_class(
+        input_file,
+        format_column_name="FORMAT",
+        primary_id_column_name=primary_id_column_name,
+    )
+
+    sample_data = sample_reader.read_rows(block_start=0, block_size=1)
+
+    # Check orbit format in the file
+    input_format = None
+    if "FORMAT" in sample_data.dtype.names:
+        input_format = sample_data["FORMAT"][0]
+    else:
+        logger.error("Input file does not contain 'FORMAT' column")
+
+    format = input_format
+
+    if format in ["CART", "BCART_EQ"]:
+        orbit_para = ["x", "y", "z", "xdot", "ydot", "zdot"]
+        orbit_para_sigma = ["sigma_x", "sigma_y", "sigma_z", "sigma_xdot", "sigma_ydot", "sigma_zdot"]
+    elif format in ["KEP", "BKEP"]:
+        orbit_para = ["a", "e", "inc", "node", "argPeri", "ma"]
+        orbit_para_sigma = ["sigma_a", "sigma_e", "sigma_inc", "sigma_node", "sigma_argPeri", "sigma_ma"]
+    elif format in ["COM", "BCOM"]:
+        orbit_para = ["q", "e", "inc", "node", "argPeri", "t_p_MJD_TDB"]
+        orbit_para_sigma = [
+            "sigma_q",
+            "sigma_e",
+            "sigma_inc",
+            "sigma_node",
+            "sigma_argPeri",
+            "sigma_t_p_MJD_TDB",
+        ]
+
+    _RESULT_DTYPES = _get_result_dtypes(primary_id_column_name, orbit_para, orbit_para_sigma)
+
+    # read data
+    data = reader_class(
+        input_file,
+        format_column_name="FORMAT",
+        primary_id_column_name=primary_id_column_name,
+    ).read_rows()
+
+    # loop that parses each row/object and unpacked
+    for row in data:
+
+        res = parse_fit_result(
+            row
+        )  # res.epoch is converted to jd in this function. It is converted back to mjd in output
+        res_unpacked = unpack(res, row[0], orbit_para, _RESULT_DTYPES)
+
+        # All results go to a single output file
+        if file_format == "hdf5":
+            write_hdf5(res_unpacked, output_file, key="data")
+        else:
+            write_csv(res_unpacked, output_file)
+
+    print(f"Data has been written to {output_file}")
diff --git a/src/layup_cmdline/main.py b/src/layup_cmdline/main.py
index 68ead55b..54c01d20 100644
--- a/src/layup_cmdline/main.py
+++ b/src/layup_cmdline/main.py
@@ -38,6 +38,8 @@ def main():
         "   convert        convert orbits to different formats\n"
         "   predict        predict on-sky positions\n"
         "   visualize      visualize orbits\n"
+        "   demo           gives demos of the other verbs\n"
+        "   unpack         unpacks covarience matrix into uncertainties"
         "   comet          determines original orbit for comets\n"
         "\n"
         "To get more information, run the verb with --help. For example:\n\n"
diff --git a/src/layup_cmdline/unpack.py b/src/layup_cmdline/unpack.py
new file mode 100644
index 00000000..e42ebf87
--- /dev/null
+++ b/src/layup_cmdline/unpack.py
@@ -0,0 +1,87 @@
+#
+# The `layup unpack` subcommand implementation
+#
+import argparse
+from layup_cmdline.layupargumentparser import LayupArgumentParser
+import logging
+import sys
+
+logger = logging.getLogger(__name__)
+
+
+def main():
+    parser = LayupArgumentParser(
+        prog="layup unpack",
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+        description="This would unpack covariance matrix into the orbital uncertainties",
+    )
+
+    positionals = parser.add_argument_group("Positional arguments")
+    positionals.add_argument(
+        help="input orbit file",
+        dest="input",
+        type=str,
+    )
+
+    optional = parser.add_argument_group("Optional arguments")
+
+    optional.add_argument(
+        "-f",
+        "--force",
+        action="store_true",
+        help="Overwrite output file",
+    )
+    optional.add_argument(
+        "-i",
+        "--input-type",
+        help="input format type of file",
+        dest="i",
+        type=str,
+        default="csv",
+        required=False,
+    )
+    optional.add_argument(
+        "-o",
+        "--output",
+        help="output file stem. default path is current working directory",
+        dest="o",
+        type=str,
+        default="unpacked_output",
+        required=False,
+    )
+
+    args = parser.parse_args()
+
+    return execute(args)
+
+
+def execute(args):
+    from layup.utilities.cli_utilities import warn_or_remove_file
+    from layup.utilities.file_access_utils import find_file_or_exit, find_directory_or_exit
+
+    # check input exists
+    find_file_or_exit(args.input, "input")
+
+    # Check that output directory exists
+    find_directory_or_exit(args.o, "-o, --")
+    # check format of input file
+    if args.i.lower() == "csv":
+        output_file = args.o + ".csv"
+    elif args.i.lower() == "hdf5":
+        output_file = args.o + ".h5"
+    else:
+        sys.exit("ERROR: File format must be 'csv' or 'hdf5'")
+
+    # check for overwriting output file
+    warn_or_remove_file(str(output_file), args.force, logger)
+    from layup.unpack import unpack_cli
+
+    unpack_cli(
+        input=args.input,
+        file_format=args.i,
+        output_file_stem=args.o,
+    )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/data/unpack_test_orbitfit_output.csv b/tests/data/unpack_test_orbitfit_output.csv
new file mode 100644
index 00000000..87dc0fc5
--- /dev/null
+++ b/tests/data/unpack_test_orbitfit_output.csv
@@ -0,0 +1,4 @@
+provID,csq,ndof,x,y,z,xdot,ydot,zdot,epochMJD_TDB,niter,method,flag,FORMAT,cov_0_0,cov_0_1,cov_0_2,cov_0_3,cov_0_4,cov_0_5,cov_1_0,cov_1_1,cov_1_2,cov_1_3,cov_1_4,cov_1_5,cov_2_0,cov_2_1,cov_2_2,cov_2_3,cov_2_4,cov_2_5,cov_3_0,cov_3_1,cov_3_2,cov_3_3,cov_3_4,cov_3_5,cov_4_0,cov_4_1,cov_4_2,cov_4_3,cov_4_4,cov_4_5,cov_5_0,cov_5_1,cov_5_2,cov_5_3,cov_5_4,cov_5_5
+119839,216.43212823565446,1168,-1.6316204272587358,-1.9450931570124754,-1.2574698501553465,0.008700948753860722,-0.005398867430414615,-0.0024738980157911167,60460.35191138787,0,orbit_fit,0,BCART_EQ,1.881469011018993e-12,-8.673572815588976e-13,-4.3364254000476657e-13,3.753934058321017e-15,3.1967726655898033e-15,2.0675598381372853e-15,-8.673572815593402e-13,7.887980722315561e-13,-1.934886035260313e-13,-1.6592521553019985e-15,-1.5728911995890328e-15,-1.905595014918336e-15,-4.336425400039571e-13,-1.9348860352652116e-13,8.552399795003714e-13,-7.794200540679248e-16,-1.5077997789351376e-15,5.189159648804451e-16,3.753934058321237e-15,-1.6592521553012201e-15,-7.794200540695813e-16,7.859107581411016e-18,6.477765220620071e-18,3.96740131293788e-18,3.19677266558953e-15,-1.5728911995880911e-15,-1.507799778936519e-15,6.47776522061918e-18,9.089576089920141e-18,2.294108848397013e-18,2.0675598381378458e-15,-1.9055950149181594e-15,5.189159648794561e-16,3.967401312938725e-18,2.2941088483983647e-18,7.721955842380198e-18
+230891,172.0017320280253,1168,-2.465106645358618,0.6092483824641353,0.6034268883192654,-0.006348776694364073,-0.007990896553032074,-0.004008846931832742,60407.404219538905,0,orbit_fit,0,BCART_EQ,1.842795173362939e-13,7.86447625847263e-14,5.201164100755452e-14,-6.015970536193818e-16,-2.657637328609589e-16,-1.0300013680764566e-16,7.864476258489971e-14,3.844034486835781e-13,7.914836951520812e-14,-1.030230169386859e-15,7.545680094905855e-16,4.92248853100572e-16,5.201164100759584e-14,7.914836951507709e-14,2.8124973852399577e-13,-5.424993654035506e-16,4.501437631719886e-16,3.363447229754989e-16,-6.015970536198918e-16,-1.0302301693863224e-15,-5.424993654038067e-16,4.551951896709508e-18,-2.0380898718440996e-18,-8.45929722110036e-19,-2.657637328606258e-16,7.545680094907116e-16,4.501437631724767e-16,-2.038089871845835e-18,5.043972369678883e-18,-1.012815353128214e-18,-1.0300013680696666e-16,4.922488531009421e-16,3.3634472297569343e-16,-8.459297221125747e-19,-1.0128153531290278e-18,6.522939259572336e-18
+609631,10.734842850222432,212,-0.703241426501453,-1.9078520094657943,-0.7350825219060013,0.011134835170826708,-0.004465537843068427,-0.0019869856265518743,60114.37905094167,0,orbit_fit,0,BCART_EQ,2.0713426787182284e-12,-5.592933330287396e-13,-2.345585139535195e-13,3.520045445944495e-15,6.554403095918803e-15,2.5582828136032757e-15,-5.592933330290515e-13,6.427935670021385e-13,-4.17619068075262e-13,8.651972950871149e-17,-2.6444476803240643e-15,-1.7548876343286964e-15,-2.345585139530062e-13,-4.176190680755573e-13,1.568610583789853e-12,-3.070955311154087e-17,-1.8838543607440852e-15,1.0579697962072666e-15,3.5200454459440666e-15,8.651972950922255e-17,-3.0709553112013116e-17,1.1367506958350318e-17,9.72991919431664e-18,4.177451101307726e-18,6.554403095918527e-15,-2.6444476803226148e-15,-1.8838543607464396e-15,9.729919194317767e-18,3.67864799796463e-17,3.367343157743466e-18,2.5582828136047663e-15,-1.7548876343289804e-15,1.05796979620662e-15,4.177451101310262e-18,3.3673431577498973e-18,2.903427813817653e-17
diff --git a/tests/layup/test_unpack.py b/tests/layup/test_unpack.py
new file mode 100644
index 00000000..f8003d33
--- /dev/null
+++ b/tests/layup/test_unpack.py
@@ -0,0 +1,73 @@
+import math
+import os
+
+import numpy as np
+import pooch
+import pytest
+from numpy.testing import assert_equal
+
+from layup.unpack import unpack_cli
+from layup.utilities.data_utilities_for_tests import get_test_filepath
+from layup.utilities.file_io.CSVReader import CSVDataReader
+
+
+def test_unpack_cli(tmpdir):
+    """Test that the unpack cli works for a small CSV file."""
+    # Since the unpack CLI outputs to the current working directory, we need to change to our temp directory
+    os.chdir(tmpdir)
+
+    temp_out_file = "test_output"
+
+    # Now run the unpack cli with overwrite set to True
+    unpack_cli(
+        input=get_test_filepath("unpack_test_orbitfit_output.csv"),
+        file_format="csv",
+        output_file_stem=temp_out_file,
+    )
+
+    # Verify the unpack produced an output file
+    assert os.path.exists(temp_out_file + ".csv")
+
+    # Create a new CSV reader to read in our output file
+    output_csv_reader = CSVDataReader(temp_out_file + ".csv", "csv", primary_id_column_name="provID")
+    output_data = output_csv_reader.read_rows()
+
+    # Read the input data and get the provID column
+    input_csv_reader = CSVDataReader(
+        get_test_filepath("unpack_test_orbitfit_output.csv"), "csv", primary_id_column_name="provID"
+    )
+    input_data = input_csv_reader.read_rows()
+
+    # Verify that the number of rows outputted is the same as the number of unique provIDs in the input file
+    n_uniq_ids = sum([1 if id else 0 for id in set(input_data["provID"])])
+    assert_equal(len(output_data), n_uniq_ids)
+
+    # Verify the columns in the output data
+    expected_cols = [
+        "provID",
+        "csq",
+        "ndof",
+        "x",
+        "sigma_x",
+        "y",
+        "sigma_y",
+        "z",
+        "sigma_z",
+        "xdot",
+        "sigma_xdot",
+        "ydot",
+        "sigma_ydot",
+        "zdot",
+        "sigma_zdot",
+        "epochMJD_TDB",
+        "niter",
+        "method",
+        "flag",
+        "FORMAT",
+    ]
+    assert set(output_data.dtype.names) == set(expected_cols)
+
+    orbit_para = ["x", "y", "z", "xdot", "ydot", "zdot"]
+    for i, para in enumerate(orbit_para):
+        # checks that all values are close in value
+        assert np.allclose(output_data["sigma_" + para], np.sqrt(input_data["cov_" + str(i) + "_" + str(i)]))


Reviews:
user: login: mschwamb
body: I think you're double MJD-ing the MJD time
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T17:26:17Z
user: login: Little-Ryugu
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T10:10:38Z
user: login: mschwamb
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T19:31:17Z
user: login: bernardinelli
body: Looks good to me!!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-14T15:39:33Z

Review comments:
diff_hunk: @@ -0,0 +1,170 @@
+import numpy as np
+import logging
+from pathlib import Path
+
+from layup.utilities.data_processing_utilities import parse_fit_result
+from layup.utilities.file_io import CSVDataReader, HDF5DataReader
+from layup.utilities.file_io.file_output import write_csv, write_hdf5
+
+logger = logging.getLogger(__name__)
+
+
+INPUT_READERS = {
+    "csv": CSVDataReader,
+    "hdf5": HDF5DataReader,
+}
+
+
+def _get_result_dtypes(primary_id_column_name: str, state: list, sigma: list):
+    """Helper function to create the result dtype with the correct primary ID column name."""
+    # Define a structured dtype for format of unpacked output file
+
+    return np.dtype(
+        [
+            (primary_id_column_name, "O"),  # Object ID
+            ("csq", "f8"),  # Chi-square value
+            ("ndof", "i4"),  # Number of degrees of freedom
+            (state[0], "f8"),  # The first of 6 state vector elements
+            (sigma[0], "f8"),  # The first of 6 sigma vector elements
+            (state[1], "f8"),
+            (sigma[1], "f8"),
+            (state[2], "f8"),
+            (sigma[2], "f8"),
+            (state[3], "f8"),
+            (sigma[3], "f8"),
+            (state[4], "f8"),
+            (sigma[4], "f8"),
+            (state[5], "f8"),  # The last of 6 state vector elements
+            (sigma[5], "f8"),  # The last of 6 state vector elements
+            ("epochMJD_TDB", "f8"),  # Epoch
+            ("niter", "i4"),  # Number of iterations
+            ("method", "O"),  # Method used for orbit fitting
+            ("flag", "i4"),  # Single-character flag indicating success of the fit
+            ("FORMAT", "O"),  # Orbit format
+        ]
+    )
+
+
+def unpack(res, name, orbit_para, _RESULT_DTYPES):
+    """
+    unpacks a file containing a covarience matrix into assoicated uncertainties.
+    e.g. name, values (x6), covariance (6x6) ---> name, value_i, sigma_value_i, ....
+
+    """
+
+    j = [0, 7, 14, 21, 28, 35]  # location of cov_ii [00,11,22,33,44,55]
+    error_list = []
+    for i, orbit_para in enumerate(orbit_para):
+        error_list.append(np.sqrt(res.cov[j[i]]))
+
+    output = np.array(
+        [
+            (
+                name,
+                res.csq,
+                res.ndof,
+            )
+            + (
+                res.state[0],
+                error_list[0],
+                res.state[1],
+                error_list[1],
+                res.state[2],
+                error_list[2],
+                res.state[3],
+                error_list[3],
+                res.state[4],
+                error_list[4],
+                res.state[5],
+                error_list[5],
+            )
+            + (
+                res.epoch - 2400000.5,
path: src/layup/unpack.py
user: login: mschwamb
body: why are we subtracting off another 2400000.5? the value is already in MJD from the input
created_at: 2025-05-05T17:24:24Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,170 @@
+import numpy as np
+import logging
+from pathlib import Path
+
+from layup.utilities.data_processing_utilities import parse_fit_result
+from layup.utilities.file_io import CSVDataReader, HDF5DataReader
+from layup.utilities.file_io.file_output import write_csv, write_hdf5
+
+logger = logging.getLogger(__name__)
+
+
+INPUT_READERS = {
+    "csv": CSVDataReader,
+    "hdf5": HDF5DataReader,
+}
+
+
+def _get_result_dtypes(primary_id_column_name: str, state: list, sigma: list):
+    """Helper function to create the result dtype with the correct primary ID column name."""
+    # Define a structured dtype for format of unpacked output file
+
+    return np.dtype(
+        [
+            (primary_id_column_name, "O"),  # Object ID
+            ("csq", "f8"),  # Chi-square value
+            ("ndof", "i4"),  # Number of degrees of freedom
+            (state[0], "f8"),  # The first of 6 state vector elements
+            (sigma[0], "f8"),  # The first of 6 sigma vector elements
+            (state[1], "f8"),
+            (sigma[1], "f8"),
+            (state[2], "f8"),
+            (sigma[2], "f8"),
+            (state[3], "f8"),
+            (sigma[3], "f8"),
+            (state[4], "f8"),
+            (sigma[4], "f8"),
+            (state[5], "f8"),  # The last of 6 state vector elements
+            (sigma[5], "f8"),  # The last of 6 state vector elements
+            ("epochMJD_TDB", "f8"),  # Epoch
+            ("niter", "i4"),  # Number of iterations
+            ("method", "O"),  # Method used for orbit fitting
+            ("flag", "i4"),  # Single-character flag indicating success of the fit
+            ("FORMAT", "O"),  # Orbit format
+        ]
+    )
+
+
+def unpack(res, name, orbit_para, _RESULT_DTYPES):
+    """
+    unpacks a file containing a covarience matrix into assoicated uncertainties.
+    e.g. name, values (x6), covariance (6x6) ---> name, value_i, sigma_value_i, ....
+
+    """
+
+    j = [0, 7, 14, 21, 28, 35]  # location of cov_ii [00,11,22,33,44,55]
+    error_list = []
+    for i, orbit_para in enumerate(orbit_para):
+        error_list.append(np.sqrt(res.cov[j[i]]))
+
+    output = np.array(
+        [
+            (
+                name,
+                res.csq,
+                res.ndof,
+            )
+            + (
+                res.state[0],
+                error_list[0],
+                res.state[1],
+                error_list[1],
+                res.state[2],
+                error_list[2],
+                res.state[3],
+                error_list[3],
+                res.state[4],
+                error_list[4],
+                res.state[5],
+                error_list[5],
+            )
+            + (
+                res.epoch - 2400000.5,
path: src/layup/unpack.py
user: login: Little-Ryugu
body: The input is converted to JD in the function parse_fit_result(). 
https://github.com/Smithsonian/layup/blob/5b6b84ca7766588bd1fe93a9de15a2d3d2f16e40/src/layup/utilities/data_processing_utilities.py#L127 
created_at: 2025-05-06T10:10:37Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,170 @@
+import numpy as np
+import logging
+from pathlib import Path
+
+from layup.utilities.data_processing_utilities import parse_fit_result
+from layup.utilities.file_io import CSVDataReader, HDF5DataReader
+from layup.utilities.file_io.file_output import write_csv, write_hdf5
+
+logger = logging.getLogger(__name__)
+
+
+INPUT_READERS = {
+    "csv": CSVDataReader,
+    "hdf5": HDF5DataReader,
+}
+
+
+def _get_result_dtypes(primary_id_column_name: str, state: list, sigma: list):
+    """Helper function to create the result dtype with the correct primary ID column name."""
+    # Define a structured dtype for format of unpacked output file
+
+    return np.dtype(
+        [
+            (primary_id_column_name, "O"),  # Object ID
+            ("csq", "f8"),  # Chi-square value
+            ("ndof", "i4"),  # Number of degrees of freedom
+            (state[0], "f8"),  # The first of 6 state vector elements
+            (sigma[0], "f8"),  # The first of 6 sigma vector elements
+            (state[1], "f8"),
+            (sigma[1], "f8"),
+            (state[2], "f8"),
+            (sigma[2], "f8"),
+            (state[3], "f8"),
+            (sigma[3], "f8"),
+            (state[4], "f8"),
+            (sigma[4], "f8"),
+            (state[5], "f8"),  # The last of 6 state vector elements
+            (sigma[5], "f8"),  # The last of 6 state vector elements
+            ("epochMJD_TDB", "f8"),  # Epoch
+            ("niter", "i4"),  # Number of iterations
+            ("method", "O"),  # Method used for orbit fitting
+            ("flag", "i4"),  # Single-character flag indicating success of the fit
+            ("FORMAT", "O"),  # Orbit format
+        ]
+    )
+
+
+def unpack(res, name, orbit_para, _RESULT_DTYPES):
+    """
+    unpacks a file containing a covarience matrix into assoicated uncertainties.
+    e.g. name, values (x6), covariance (6x6) ---> name, value_i, sigma_value_i, ....
+
+    """
+
+    j = [0, 7, 14, 21, 28, 35]  # location of cov_ii [00,11,22,33,44,55]
+    error_list = []
+    for i, orbit_para in enumerate(orbit_para):
+        error_list.append(np.sqrt(res.cov[j[i]]))
+
+    output = np.array(
+        [
+            (
+                name,
+                res.csq,
+                res.ndof,
+            )
+            + (
+                res.state[0],
+                error_list[0],
+                res.state[1],
+                error_list[1],
+                res.state[2],
+                error_list[2],
+                res.state[3],
+                error_list[3],
+                res.state[4],
+                error_list[4],
+                res.state[5],
+                error_list[5],
+            )
+            + (
+                res.epoch - 2400000.5,
path: src/layup/unpack.py
user: login: mschwamb
body: gotcha - worth adding a comment to note this I think
created_at: 2025-05-07T19:31:17Z
author_association: COLLABORATOR

PR comments:
user: login: mschwamb
created_at: 2025-05-08T06:47:41Z
body: @Little-Ryugu  is this still a WIP or can Pedro review?
author_association: COLLABORATOR
user: login: Little-Ryugu
created_at: 2025-05-08T09:59:39Z
body: Pedro can review it's finished
author_association: COLLABORATOR