Repository: isofit/isofit

Issue:
examples not running
I'm trying to use ISOFIT for the first time and the python and shell scripts in examples don't seem to run.  My short-term goal is to be able to use ISOFIT to calibrate an AVIRIS or AVIRIS-NG cube to reflectance as a step towards calibrating data from another VNIR/SWIM sensor.  What's the best way to get started?  I'm not having success following the docs as almost all of the examples I've tried fail.

Following https://registry.hub.docker.com/r/jammont/isofit

```
E:\isofit_test>docker run --rm -it --shm-size=16gb jammont/isofit:3.2.2 bash examples/image_cube/small/analytical.sh
2024-12-03 14:29:01,178 INFO worker.py:1819 -- Started a local Ray instance.
INFO:2024-12-03,14:29:01 || apply_oe.py:apply_oe() | namespace(surface_path='/home/mambauser/examples/image_cube/small/configs/surface.json', emulator_base='/home/mambauser/srtmnet/sRTMnet_v120.h5', n_cores=4, presolve=True, segmentation_size=400, pressure_elevation=True, analytical_line=True, input_radiance='/home/mambauser/imagecube/small/ang20170323t202244_rdn_7000-7010', input_loc='/home/mambauser/imagecube/small/ang20170323t202244_obs_7000-7010', input_obs='/home/mambauser/imagecube/small/ang20170323t202244_loc_7000-7010', working_directory='/home/mambauser/examples/image_cube/small', sensor='ang', copy_input_files=False, modtran_path=None, wavelength_path=None, surface_category='multicomponent_surface', aerosol_climatology_path=None, rdn_factors_path=None, atmosphere_type='ATM_MIDLAT_SUMMER', channelized_uncertainty_path=None, model_discrepancy_path=None, lut_config_file=None, multiple_restarts=False, logging_level='INFO', log_file=None, num_cpus=1, memory_gb=-1, empirical_line=False, ray_temp_dir='/tmp/ray', num_neighbors=(), atm_sigma=(2.0,), prebuilt_lut=None, inversion_windows=(), no_min_lut_spacing=False)
INFO:2024-12-03,14:29:01 || apply_oe.py:apply_oe() | Checking input data files...
INFO:2024-12-03,14:29:01 || apply_oe.py:apply_oe() | ...Data file checks complete
INFO:2024-12-03,14:29:01 || apply_oe.py:apply_oe() | Setting up files and directories....
INFO:2024-12-03,14:29:01 || template_construction.py:__init__() | Flightline ID: ang20170323t202244
INFO:2024-12-03,14:29:01 || template_construction.py:__init__() | no noise path found, proceeding without
INFO:2024-12-03,14:29:01 || template_construction.py:stage_files() | Staging /home/mambauser/examples/image_cube/small/configs/surface.json to /home/mambauser/examples/image_cube/small/data/surface.mat
INFO:2024-12-03,14:29:01 || apply_oe.py:apply_oe() | ...file/directory setup complete
INFO:2024-12-03,14:29:01 || apply_oe.py:apply_oe() | Using inversion windows: [[350.0, 1360.0], [1410, 1800.0], [1970.0, 2500.0]]
Traceback (most recent call last):
  File "/opt/conda/envs/isofit/bin/isofit", line 8, in <module>
    sys.exit(cli())
  File "/opt/conda/envs/isofit/lib/python3.10/site-packages/click/core.py", line 1157, in __call__
    return self.main(*args, **kwargs)
  File "/opt/conda/envs/isofit/lib/python3.10/site-packages/click/core.py", line 1078, in main
    rv = self.invoke(ctx)
  File "/opt/conda/envs/isofit/lib/python3.10/site-packages/click/core.py", line 1688, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
  File "/opt/conda/envs/isofit/lib/python3.10/site-packages/click/core.py", line 1434, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/opt/conda/envs/isofit/lib/python3.10/site-packages/click/core.py", line 783, in invoke
    return __callback(*args, **kwargs)
  File "/home/mambauser/isofit/isofit/utils/apply_oe.py", line 102, in cli_apply_oe
    apply_oe(SimpleNamespace(**kwargs))
  File "/home/mambauser/isofit/isofit/utils/apply_oe.py", line 312, in apply_oe
    ) = tmpl.get_metadata_from_obs(paths.obs_working_path, lut_params)
  File "/home/mambauser/isofit/isofit/utils/template_construction.py", line 1457, in get_metadata_from_obs
    to_sun_azimuth = obs[:, :, 3]
  File "/opt/conda/envs/isofit/lib/python3.10/site-packages/numpy/core/memmap.py", line 335, in __getitem__
    res = super().__getitem__(index)
IndexError: index 3 is out of bounds for axis 2 with size 3
```

Running python scripts interactively in docker bash shell (so conda environment is activated).


```
E:\isofit_test>docker run --rm -it --shm-size=16gb jammont/isofit:3.2.2 bash
(isofit) mambauser@9dbb967b0d08:~$
(isofit) mambauser@9dbb967b0d08:~$ cd examples/scripts/
(isofit) mambauser@09e3524786fc:~/examples/scripts$ ls
test_6S.py  test_modtran.py  test_sRTMnet.py
(isofit) mambauser@09e3524786fc:~/examples/scripts$ python test_6S.py
Traceback (most recent call last):
  File "/home/mambauser/examples/scripts/test_6S.py", line 8, in <module>
    from isofit.radiative_transfer.six_s import SixSRT
ModuleNotFoundError: No module named 'isofit.radiative_transfer.six_s'
(isofit) mambauser@9dbb967b0d08:~/examples/scripts$ python test_sRTMnet.py
Traceback (most recent call last):
  File "/home/mambauser/examples/scripts/test_sRTMnet.py", line 7, in <module>
    from isofit.radiative_transfer.sRTMnet import SimulatedModtranRT
ModuleNotFoundError: No module named 'isofit.radiative_transfer.sRTMnet'
```
I've also had similar issues trying to use the jupyter notebooks in isotutorials.

Discussion:
@jammont: @unbohn the `IndexError` is fixed by https://github.com/isofit/isofit-tutorials/pull/12, right?

As for the second issue with executing the scripts directly in the container, those files under the `scripts/` directory are:
1. out of date
2. intended to be used by developers and not as an example for users

We'll seek to update these and add some documentation to indicate the above intentions. For now, I recommend leaning on the SantaMonic and Pasadena examples as those should both be fully working. @unbohn: @jammont Yes, that's correct.@pgbrodrick: Resolved with latest release!

Solution patch:
diff --git a/.github/workflows/test-pip-package-ubuntu.yml b/.github/workflows/test-pip-package-ubuntu.yml
index 48c0c0f8..76492bff 100644
--- a/.github/workflows/test-pip-package-ubuntu.yml
+++ b/.github/workflows/test-pip-package-ubuntu.yml
@@ -38,37 +38,39 @@ jobs:
     steps:
       - name: Cache Extras
         id: cache-extras
-        uses: actions/cache@v3
+        uses: actions/cache/restore@v4
         with:
           path: ~/.isofit
           key: cache-extras
 
-      - if: ${{ steps.cache-extras.outputs.cache-hit != 'true' }}
-        uses: actions/checkout@v3
-
-      - if: ${{ steps.cache-extras.outputs.cache-hit != 'true' }}
-        name: Print concurrency group
-        run: echo '${{ github.workflow }}-${{ github.ref }}'
+      - uses: actions/checkout@v3
 
-      - if: ${{ steps.cache-extras.outputs.cache-hit != 'true' }}
-        name: Set up Python ${{ matrix.python-version }}
+      - name: Set up Python
         uses: actions/setup-python@v3
         with:
           python-version: '3.12'
           cache: 'pip'
 
-      - if: ${{ steps.cache-extras.outputs.cache-hit != 'true' }}
-        name: Install ISOFIT
+      - name: Install ISOFIT
         shell: bash
         run: |
           python3 -m pip install --editable ".[test]"
 
-      - if: ${{ steps.cache-extras.outputs.cache-hit != 'true' }}
-        name: Download extras
+      - name: Download Extras
         shell: bash
         run: |
-          isofit download all
-          isofit build
+          if isofit download all --update | grep "Executing update"; then
+            echo "CACHE_UPDATE=true" >> $GITHUB_ENV
+          else
+            echo "CACHE_UPDATE=false" >> $GITHUB_ENV
+          fi
+
+      - if: env.CACHE_UPDATE == 'true'
+        name: Update Cache
+        uses: actions/cache/save@v4
+        with:
+          path: ~/.isofit
+          key: cache-extras
 
   test:
     needs: cache
diff --git a/.github/workflows/test-pythonpath-ubuntu.yml b/.github/workflows/test-pythonpath-ubuntu.yml
index 0e706d32..99d2c86e 100644
--- a/.github/workflows/test-pythonpath-ubuntu.yml
+++ b/.github/workflows/test-pythonpath-ubuntu.yml
@@ -19,37 +19,39 @@ jobs:
     steps:
       - name: Cache Extras
         id: cache-extras
-        uses: actions/cache@v3
+        uses: actions/cache/restore@v4
         with:
           path: ~/.isofit
           key: cache-extras
 
-      - if: ${{ steps.cache-extras.outputs.cache-hit != 'true' }}
-        uses: actions/checkout@v3
-
-      - if: ${{ steps.cache-extras.outputs.cache-hit != 'true' }}
-        name: Print concurrency group
-        run: echo '${{ github.workflow }}-${{ github.ref }}'
+      - uses: actions/checkout@v3
 
-      - if: ${{ steps.cache-extras.outputs.cache-hit != 'true' }}
-        name: Set up Python ${{ matrix.python-version }}
+      - name: Set up Python
         uses: actions/setup-python@v3
         with:
           python-version: '3.12'
           cache: 'pip'
 
-      - if: ${{ steps.cache-extras.outputs.cache-hit != 'true' }}
-        name: Install ISOFIT
+      - name: Install ISOFIT
         shell: bash
         run: |
           python3 -m pip install --editable ".[test]"
 
-      - if: ${{ steps.cache-extras.outputs.cache-hit != 'true' }}
-        name: Download extras
+      - name: Download Extras
         shell: bash
         run: |
-          isofit download all
-          isofit build
+          if isofit download all --update | grep "Executing update"; then
+            echo "CACHE_UPDATE=true" >> $GITHUB_ENV
+          else
+            echo "CACHE_UPDATE=false" >> $GITHUB_ENV
+          fi
+
+      - if: env.CACHE_UPDATE == 'true'
+        name: Update Cache
+        uses: actions/cache/save@v4
+        with:
+          path: ~/.isofit
+          key: cache-extras
 
   test:
     needs: cache
diff --git a/isofit/__init__.py b/isofit/__init__.py
index c8b21b1a..6a49c430 100644
--- a/isofit/__init__.py
+++ b/isofit/__init__.py
@@ -37,9 +37,11 @@ def checkNumThreads():
     Checks the num_threads setting in the environment and raises a strong warning if it
     is not set to 1 .
     """
+    threads = "unknown"
     error = False
     if info := threadpool_info():
-        if info[0]["num_threads"] > 1:
+        threads = info[0]["num_threads"]
+        if threads > 1:
             error = "greater than"
     else:
         error = "not set to"
@@ -48,7 +50,7 @@ def checkNumThreads():
         Logger.warning(
             f"""
 ******************************************************************************************
-! Number of threads is {error} 1, this may greatly impact performance
+! Number of threads is {error} 1 (currently: {threads}), this may greatly impact performance
 ! Please set this the environment variables 'MKL_NUM_THREADS' and 'OMP_NUM_THREADS' to '1'
 ******************************************************************************************\
 """
diff --git a/isofit/__main__.py b/isofit/__main__.py
index 8068b12b..eec5ccd7 100644
--- a/isofit/__main__.py
+++ b/isofit/__main__.py
@@ -41,10 +41,23 @@ from isofit.utils.surface_model import cli_surface_model
 @click.option("-em", "--srtmnet", help="Override path to sRTMnet installation")
 @click.option("-6s", "--sixs", help="Override path to SixS installation")
 # @click.option("-mt", "--modtran", help="Override path to MODTRAN installation")
+@click.option(
+    "-k",
+    "--keys",
+    nargs=2,
+    multiple=True,
+    help="Override keys with the format `-k [key] [value]`",
+)
 @click.option(
     "--save/--no-save", " /-S", is_flag=True, default=True, help="Save the ini file"
 )
-def cli(ctx, version, ini, base, section, save, **overrides):
+@click.option(
+    "-p",
+    "--preview",
+    is_flag=True,
+    help="Prints the environment that will be used. This disables saving",
+)
+def cli(ctx, version, ini, base, section, keys, save, preview, **overrides):
     """\
     This houses the subcommands of ISOFIT
     """
@@ -62,7 +75,13 @@ def cli(ctx, version, ini, base, section, save, **overrides):
         if value:
             env.changePath(key, value)
 
-    env.save(diff_only=save)
+    for key, value in keys:
+        env.changeKey(key, value)
+
+    if preview:
+        print(env)
+    else:
+        env.save(diff_only=save)
 
 
 # Subcommands live closer to the code and algorithms they are related to.
@@ -75,7 +94,6 @@ cli.add_command(cli_apply_oe)
 cli.add_command(cli_sun)
 cli.add_command(cli_env_path)
 cli.add_command(dli.download)
-cli.add_command(dli.validate)
 cli.add_command(cli_build)
 cli.add_command(cli_6s_to_srtmnet)
 cli.add_command(cli_surface_model)
diff --git a/isofit/data/__init__.py b/isofit/data/__init__.py
index a1ae1bd8..71bf4902 100644
--- a/isofit/data/__init__.py
+++ b/isofit/data/__init__.py
@@ -9,7 +9,7 @@ import isofit.data.cli
 
 
 @click.command(name="path")
-@click.argument("product", type=click.Choice(env.dirs))
+@click.argument("product", type=click.Choice(env._dirs))
 def cli_env_path(product):
     """\
     Prints the path to a specific product
diff --git a/isofit/data/cli/__init__.py b/isofit/data/cli/__init__.py
index 4a22b7ec..84f17a8c 100644
--- a/isofit/data/cli/__init__.py
+++ b/isofit/data/cli/__init__.py
@@ -12,33 +12,31 @@ Modules = {
 
 
 @cli.download.command(name="all")
-def download_all():
+@cli.update
+@cli.check
+@cli.validate
+def download_all(update_, check, validate_):
     """\
-    Downloads all ISOFIT extra dependencies to the locations specified in the isofit.ini file using latest tags and versions.
+    Downloads all ISOFIT extra dependencies to the locations specified in the isofit.ini file using latest tags and versions
     """
     pad = "=" * 16
 
     for i, module in enumerate(Modules.values()):
-        print(f"{pad} Beginning download {i+1} of {len(Modules)} {pad}")
-        module.download()
-        print()
-
-    print("Finished all downloads")
+        if update_:
+            print(f"{pad} Beginning update {i+1} of {len(Modules)} {pad}")
+            module.update(check)
 
+        elif validate_:
+            print(f"{pad} Beginning validation {i+1} of {len(Modules)} {pad}")
+            module.validate()
 
-@cli.validate.command(name="all")
-def validate_all():
-    """\
-    Validates all ISOFIT extra dependencies at the locations specified in the isofit.ini file.
-    """
-    pad = "=" * 16
+        else:
+            print(f"{pad} Beginning download {i+1} of {len(Modules)} {pad}")
+            module.download()
 
-    for i, module in enumerate(Modules.values()):
-        print(f"{pad} Validating {i+1} of {len(Modules)} {pad}")
-        module.validate()
         print()
 
-    print("Finished all validations")
+    print("Finished all processes")
 
 
 def env_validate(keys, **kwargs):
@@ -52,6 +50,10 @@ def env_validate(keys, **kwargs):
     """
     error = kwargs.get("error", print)
 
+    # Turn off checking for updates when using this function by default
+    # This makes env.path less verbose
+    kwargs["checkUpdate"] = kwargs.get("checkUpdate", False)
+
     all_valid = True
     for key in keys:
         module = Modules.get(key)
diff --git a/isofit/data/cli/data.py b/isofit/data/cli/data.py
index 77de5147..cb0299ac 100644
--- a/isofit/data/cli/data.py
+++ b/isofit/data/cli/data.py
@@ -14,20 +14,22 @@ from isofit.data.download import (
 )
 
 
-def download(output=None, tag="latest"):
+def download(path=None, tag="latest", overwrite=False):
     """
     Downloads the extra ISOFIT data files from the repository https://github.com/isofit/isofit-data.
 
     Parameters
     ----------
-    output: str | None
-        Path to output as. If None, defaults to the ini path.
-    tag: str
-        Release tag to pull from the github.
+    path : str | None
+        Path to output as. If None, defaults to the ini path
+    tag : str
+        Release tag to pull from the github
+    overwrite : bool, default=False
+        Overwrite an existing installation
     """
     print(f"Downloading ISOFIT data")
 
-    output = prepare_output(output, env.data)
+    output = prepare_output(path, env.data, overwrite=overwrite)
     if not output:
         return
 
@@ -37,29 +39,15 @@ def download(output=None, tag="latest"):
     zipfile = download_file(metadata["zipball_url"], output.parent / "isofit-data.zip")
 
     print(f"Unzipping {zipfile}")
-    avail = unzip(zipfile, path=output.parent, rename=output.name)
-
-    print(f"Done, now available at: {avail}")
+    avail = unzip(zipfile, path=output.parent, rename=output.name, overwrite=overwrite)
 
+    with open(output / "version.txt", "w") as file:
+        file.write(metadata["tag_name"])
 
-@cli.download.command(name="data")
-@cli.output(help="Root directory to download data files to, ie. [path]/data")
-@cli.tag
-def download_cli(**kwargs):
-    """\
-    Downloads the extra ISOFIT data files from the repository https://github.com/isofit/isofit-data.
-
-    \b
-    Run `isofit download paths` to see default path locations.
-    There are two ways to specify output directory:
-        - `isofit --data /path/data download data`: Override the ini file. This will save the provided path for future reference.
-        - `isofit download data --output /path/data`: Temporarily set the output location. This will not be saved in the ini and may need to be manually set.
-    It is recommended to use the first style so the download path is remembered in the future.
-    """
-    download(**kwargs)
+    print(f"Done, now available at: {avail}")
 
 
-def validate(path=None, debug=print, error=print, **_):
+def validate(path=None, checkUpdate=True, debug=print, error=print, **_):
     """
     Validates an ISOFIT data installation
 
@@ -67,6 +55,8 @@ def validate(path=None, debug=print, error=print, **_):
     ----------
     path : str, default=None
         Path to verify. If None, defaults to the ini path
+    checkUpdate : bool, default=True
+        Checks for updates if the path is valid
     debug : function, default=print
         Print function to use for debug messages, eg. logging.debug
     error : function, default=print
@@ -105,13 +95,99 @@ def validate(path=None, debug=print, error=print, **_):
         return False
 
     debug("Path is valid")
+
+    if checkUpdate:
+        checkForUpdate(path, debug=debug, error=error)
+
     return True
 
 
-@cli.validate.command(name="data")
-@cli.path(help="Path to an ISOFIT data installation")
-def validate_cli(**kwargs):
+def checkForUpdate(path=None, tag="latest", debug=print, error=print, **_):
+    """
+    Checks the installed version against the latest release
+
+    Parameters
+    ----------
+    path : str, default=None
+        Path to update. If None, defaults to the ini path
+    debug : function, default=print
+        Print function to use for debug messages, eg. logging.debug
+    error : function, default=print
+        Print function to use for error messages, eg. logging.error
+    **_ : dict
+        Ignores unused params that may be used by other validate functions. This is to
+        maintain compatibility with other functions
+
+    Returns
+    -------
+    bool
+        True if there is a version update, else False
+    """
+    if path is None:
+        path = env.data
+
+    debug(f"Checking for updates for data on path: {path}")
+
+    file = Path(path) / "version.txt"
+    if not file.exists():
+        error(
+            "Failed to find a version.txt file under the given path. Version is unknown. It is recommended to redownload via `isofit download data --overwrite`"
+        )
+        return True
+
+    metadata = release_metadata("isofit", "isofit-data", tag)
+    with open(file, "r") as f:
+        version = f.read()
+
+    if version != (latest := metadata["tag_name"]):
+        error(
+            f"Your data is out of date and may cause issues. Latest is {latest}, currently installed is {version}. Please update via `isofit download data --update`"
+        )
+        return True
+
+    debug("Path is up to date")
+
+    return False
+
+
+def update(check=False, **kwargs):
+    """
+    Checks for an update and executes a new download if it is needed
+
+    Parameters
+    ----------
+    check : bool, default=False
+        Just check if an update is available, do not download
+    **kwargs : dict
+        Additional key-word arguments to pass to download()
+    """
+    kwargs["overwrite"] = True
+    if checkForUpdate(**kwargs) and not check:
+        kwargs.get("debug", print)("Executing update")
+        download(**kwargs)
+
+
+@cli.download.command(name="data")
+@cli.path(help="Root directory to download data files to, ie. [path]/data")
+@cli.tag
+@cli.overwrite
+@cli.update
+@cli.check
+@cli.validate
+def download_cli(update_, check, validate_, **kwargs):
     """\
-    Validates an ISOFIT data installation
+    Downloads the extra ISOFIT data files from the repository https://github.com/isofit/isofit-data.
+
+    \b
+    Run `isofit download paths` to see default path locations.
+    There are two ways to specify output directory:
+        - `isofit --data /path/data download data`: Override the ini file. This will save the provided path for future reference.
+        - `isofit download data --output /path/data`: Temporarily set the output location. This will not be saved in the ini and may need to be manually set.
+    It is recommended to use the first style so the download path is remembered in the future.
     """
-    validate(**kwargs)
+    if update_:
+        update(check, **kwargs)
+    elif validate_:
+        validate(**kwargs)
+    else:
+        download(**kwargs)
diff --git a/isofit/data/cli/examples.py b/isofit/data/cli/examples.py
index 34eacc1b..5c7cf5bc 100644
--- a/isofit/data/cli/examples.py
+++ b/isofit/data/cli/examples.py
@@ -27,7 +27,7 @@ def download_neon(examples):
     """
     print("Downloading NEON data for the example")
 
-    output = prepare_output(examples / "isotuts/NEON/data", ...)
+    output = prepare_output(examples / "isotuts/NEON/data", "./neon_data")
     if not output:
         return
 
@@ -37,20 +37,22 @@ def download_neon(examples):
     avail = unzip(zipfile, path=output.parent, rename=output.name)
 
 
-def download(output=None, tag="latest"):
+def download(path=None, tag="latest", overwrite=False):
     """
     Downloads the ISOFIT examples from the repository https://github.com/isofit/isofit-tutorials.
 
     Parameters
     ----------
-    output: str | None
-        Path to output as. If None, defaults to the ini path.
-    tag: str
-        Release tag to pull from the github.
+    path : str | None
+        Path to output as. If None, defaults to the ini path
+    tag : str
+        Release tag to pull from the github
+    overwrite : bool, default=False
+        Overwrite an existing installation
     """
     print(f"Downloading ISOFIT examples")
 
-    output = prepare_output(output, env.examples)
+    output = prepare_output(path, env.examples, overwrite=overwrite)
     if not output:
         return
 
@@ -62,31 +64,17 @@ def download(output=None, tag="latest"):
     )
 
     print(f"Unzipping {zipfile}")
-    avail = unzip(zipfile, path=output.parent, rename=output.name)
+    avail = unzip(zipfile, path=output.parent, rename=output.name, overwrite=overwrite)
+
+    with open(output / "version.txt", "w") as file:
+        file.write(metadata["tag_name"])
 
     download_neon(output)
 
     print(f"Done, now available at: {avail}")
 
 
-@cli.download.command(name="examples")
-@cli.output(help="Root directory to download ISOFIT examples to, ie. [path]/examples")
-@cli.tag
-def download_cli(**kwargs):
-    """\
-    Downloads the ISOFIT examples from the repository https://github.com/isofit/isofit-tutorials.
-
-    \b
-    Run `isofit download paths` to see default path locations.
-    There are two ways to specify output directory:
-        - `isofit --examples /path/examples download examples`: Override the ini file. This will save the provided path for future reference.
-        - `isofit download examples --output /path/examples`: Temporarily set the output location. This will not be saved in the ini and may need to be manually set.
-    It is recommended to use the first style so the download path is remembered in the future.
-    """
-    download(**kwargs)
-
-
-def validate(path=None, debug=print, error=print, **_):
+def validate(path=None, checkUpdate=True, debug=print, error=print, **_):
     """
     Validates an ISOFIT examples installation
 
@@ -94,6 +82,8 @@ def validate(path=None, debug=print, error=print, **_):
     ----------
     path : str, default=None
         Path to verify. If None, defaults to the ini path
+    checkUpdate : bool, default=True
+        Checks for updates if the path is valid
     debug : function, default=print
         Print function to use for debug messages, eg. logging.debug
     error : function, default=print
@@ -135,13 +125,99 @@ def validate(path=None, debug=print, error=print, **_):
         return False
 
     debug("Path is valid")
+
+    if checkUpdate:
+        checkForUpdate(path, debug=debug, error=error)
+
     return True
 
 
-@cli.validate.command(name="examples")
-@cli.path(help="Path to an ISOFIT examples installation")
-def validate_cli(**kwargs):
+def checkForUpdate(path=None, tag="latest", debug=print, error=print, **_):
+    """
+    Checks the installed version against the latest release
+
+    Parameters
+    ----------
+    path : str, default=None
+        Path to update. If None, defaults to the ini path
+    debug : function, default=print
+        Print function to use for debug messages, eg. logging.debug
+    error : function, default=print
+        Print function to use for error messages, eg. logging.error
+    **_ : dict
+        Ignores unused params that may be used by other validate functions. This is to
+        maintain compatibility with other functions
+
+    Returns
+    -------
+    bool
+        True if there is a version update, else False
+    """
+    if path is None:
+        path = env.examples
+
+    debug(f"Checking for updates for examples on path: {path}")
+
+    file = Path(path) / "version.txt"
+    if not file.exists():
+        error(
+            "Failed to find a version.txt file under the given path. Version is unknown. It is recommended to redownload via `isofit download examples --overwrite`"
+        )
+        return True
+
+    metadata = release_metadata("isofit", "isofit-tutorials", tag)
+    with open(file, "r") as f:
+        version = f.read()
+
+    if version != (latest := metadata["tag_name"]):
+        error(
+            f"Your examples are out of date and may cause issues. Latest is {latest}, currently installed is {version}. Please update via `isofit download examples --update`"
+        )
+        return True
+
+    debug("Path is up to date")
+
+    return False
+
+
+def update(check=False, **kwargs):
+    """
+    Checks for an update and executes a new download if it is needed
+
+    Parameters
+    ----------
+    check : bool, default=False
+        Just check if an update is available, do not download
+    **kwargs : dict
+        Additional key-word arguments to pass to download()
+    """
+    kwargs["overwrite"] = True
+    if checkForUpdate(**kwargs) and not check:
+        kwargs.get("debug", print)("Executing update")
+        download(**kwargs)
+
+
+@cli.download.command(name="examples")
+@cli.path(help="Root directory to download ISOFIT examples to, ie. [path]/examples")
+@cli.tag
+@cli.overwrite
+@cli.update
+@cli.check
+@cli.validate
+def download_cli(update_, check, validate_, **kwargs):
     """\
-    Validates an ISOFIT examples installation
+    Downloads the ISOFIT examples from the repository https://github.com/isofit/isofit-tutorials.
+
+    \b
+    Run `isofit download paths` to see default path locations.
+    There are two ways to specify output directory:
+        - `isofit --examples /path/examples download examples`: Override the ini file. This will save the provided path for future reference.
+        - `isofit download examples --output /path/examples`: Temporarily set the output location. This will not be saved in the ini and may need to be manually set.
+    It is recommended to use the first style so the download path is remembered in the future.
     """
-    validate(**kwargs)
+    if update_:
+        update(check, **kwargs)
+    elif validate_:
+        validate(**kwargs)
+    else:
+        download(**kwargs)
diff --git a/isofit/data/cli/image_cube.py b/isofit/data/cli/image_cube.py
index 176daeef..e98a88a9 100644
--- a/isofit/data/cli/image_cube.py
+++ b/isofit/data/cli/image_cube.py
@@ -10,29 +10,22 @@ from isofit.data import env
 from isofit.data.download import cli, download_file, prepare_output, unzip
 
 URL = "https://avng.jpl.nasa.gov/pub/PBrodrick/isofit/{size}_chunk.zip"
-Chunk = click.option(
-    "-s",
-    "--size",
-    type=click.Choice(["small", "medium", "both"]),
-    default="both",
-    help="Chunk size",
-)
 
 
-def download(output=None, size="both"):
+def download(path=None, size="both"):
     """
     Downloads the extra ISOFIT data files from https://avng.jpl.nasa.gov/pub/PBrodrick/isofit/.
 
     Parameters
     ----------
-    output : str | None
+    path : str | None
         Path to output as. If None, defaults to the ini path.
     size : "both" | "small" | "medium"
         Which chunk size to pull
     """
     if size == "both":
-        download(output, "small")
-        download(output, "medium")
+        download(path, "small")
+        download(path, "medium")
         return
 
     if size not in ("small", "medium"):
@@ -42,7 +35,7 @@ def download(output=None, size="both"):
 
     print(f"Downloading ISOFIT image cube data: {size}")
 
-    output = Path(output or env.imagecube) / size
+    output = Path(path or env.imagecube) / size
     output = prepare_output(output, None)
     if not output:
         return
@@ -58,25 +51,6 @@ def download(output=None, size="both"):
     print(f"Done, now available at: {avail}")
 
 
-@cli.download.command(name="imagecube")
-@cli.output(
-    help="Root directory to download image cube data files to, ie. [path]/imagecube"
-)
-@Chunk
-def download_cli(**kwargs):
-    """\
-    Downloads the extra ISOFIT image cube data files from https://avng.jpl.nasa.gov/pub/PBrodrick/isofit/.
-
-    \b
-    Run `isofit download paths` to see default path locations.
-    There are two ways to specify output directory:
-        - `isofit --imagecube /path/imagecube download imagecube`: Override the ini file. This will save the provided path for future reference.
-        - `isofit download imagecube --output /path/imagecube`: Temporarily set the output location. This will not be saved in the ini and may need to be manually set.
-    It is recommended to use the first style so the download path is remembered in the future.
-    """
-    download(**kwargs)
-
-
 def validate(path=None, size="both", debug=print, error=print, **_):
     """
     Validates an ISOFIT image cube data installation
@@ -122,11 +96,45 @@ def validate(path=None, size="both", debug=print, error=print, **_):
     return True
 
 
-@cli.validate.command(name="imagecube")
-@cli.path(help="Path to an image cube installation")
-@Chunk
-def validate_cli(**kwargs):
+def update(check=False, **kwargs):
+    """
+    Checks for an update and executes a new download if it is needed
+    Note: Not implemented for this module at this time
+
+    Parameters
+    ----------
+    check : bool, default=False
+        Just check if an update is available, do not download
+    **kwargs : dict
+        Additional key-word arguments to pass to download()
+    """
+    print("ImageCube does not support versioning at this time, no update to be found")
+
+
+@cli.download.command(name="imagecube")
+@cli.path(
+    help="Root directory to download image cube data files to, ie. [path]/imagecube"
+)
+@click.option(
+    "-s",
+    "--size",
+    type=click.Choice(["small", "medium", "both"]),
+    default="both",
+    help="Chunk size",
+)
+@cli.validate
+def download_cli(**kwargs):
     """\
-    Validates an ISOFIT image cube data installation
+    Downloads the extra ISOFIT image cube data files from https://avng.jpl.nasa.gov/pub/PBrodrick/isofit/.
+
+    \b
+    Run `isofit download paths` to see default path locations.
+    There are two ways to specify output directory:
+        - `isofit --imagecube /path/imagecube download imagecube`: Override the ini file. This will save the provided path for future reference.
+        - `isofit download imagecube --output /path/imagecube`: Temporarily set the output location. This will not be saved in the ini and may need to be manually set.
+    It is recommended to use the first style so the download path is remembered in the future.
     """
-    validate(**kwargs)
+    if validate_:
+        validate(**kwargs)
+    else:
+        download(**kwargs)
diff --git a/isofit/data/cli/modtran.py b/isofit/data/cli/modtran.py
index 27505b91..38aded18 100644
--- a/isofit/data/cli/modtran.py
+++ b/isofit/data/cli/modtran.py
@@ -26,23 +26,6 @@ def download(output=None):
     print("MODTRAN downloading is not supported yet")
 
 
-# @cli.download.command(name="modtran")
-# @cli.output(help="Root directory to download modtran files to, ie. [path]/modtran")
-# @cli.tag
-def download_cli(**kwargs):
-    """\
-    Downloads and installs MODTRAN
-
-    \b
-    Run `isofit download paths` to see default path locations.
-    There are two ways to specify output directory:
-        - `isofit --modtran /path/modtran download modtran`: Override the ini file. This will save the provided path for future reference.
-        - `isofit download modtran --output /path/modtran`: Temporarily set the output location. This will not be saved in the ini and may need to be manually set.
-    It is recommended to use the first style so the download path is remembered in the future.
-    """
-    download(**kwargs)
-
-
 def validate(path=None, debug=print, error=print, **_):
     """
     Validates an ISOFIT data installation
@@ -78,10 +61,33 @@ def validate(path=None, debug=print, error=print, **_):
     return True
 
 
-# @cli.validate.command(name="modtran")
-# @cli.path(help="Path to a MODTRAN installation")
-def validate_cli(**kwargs):
+def update(check=False, **kwargs):
+    """
+    Checks for an update and executes a new download if it is needed
+    Note: Not implemented for this module at this time
+
+    Parameters
+    ----------
+    check : bool, default=False
+        Just check if an update is available, do not download
+    **kwargs : dict
+        Additional key-word arguments to pass to download()
+    """
+    print("MODTRAN does not support versioning at this time, no update to be found")
+
+
+# @cli.download.command(name="modtran")
+# @cli.path(help="Root directory to download modtran files to, ie. [path]/modtran")
+# @cli.tag
+def download_cli(**kwargs):
     """\
-    Validates a MODTRAN installation
+    Downloads and installs MODTRAN
+
+    \b
+    Run `isofit download paths` to see default path locations.
+    There are two ways to specify output directory:
+        - `isofit --modtran /path/modtran download modtran`: Override the ini file. This will save the provided path for future reference.
+        - `isofit download modtran --output /path/modtran`: Temporarily set the output location. This will not be saved in the ini and may need to be manually set.
+    It is recommended to use the first style so the download path is remembered in the future.
     """
-    validate(**kwargs)
+    download(**kwargs)
diff --git a/isofit/data/cli/sixs.py b/isofit/data/cli/sixs.py
index 758dcd42..f543db20 100644
--- a/isofit/data/cli/sixs.py
+++ b/isofit/data/cli/sixs.py
@@ -59,26 +59,26 @@ def build(directory):
     )
 
 
-def download(output=None):
+def download(path=None):
     """
     Downloads 6S from https://github.com/ashiklom/isofit/releases/download/6sv-mirror/6sv-2.1.tar.
 
     Parameters
     ----------
-    output: str | None
+    output : str | None
         Path to output as. If None, defaults to the ini path.
-    version: str
+    version : str
         Release tag to pull from the github.
     """
     if not precheck():
         print(
-            "Skipping downloading 6S. Once above errors are corrected, retry via: isofit download sixs"
+            "Skipping downloading 6S. Once above errors are corrected, retry via `isofit download sixs`"
         )
         return
 
     print("Downloading 6S")
 
-    output = prepare_output(output, env.sixs)
+    output = prepare_output(path, env.sixs)
     if not output:
         return
 
@@ -92,22 +92,6 @@ def download(output=None):
     print(f"Done, now available at: {output}")
 
 
-@cli.download.command(name="sixs")
-@cli.output(help="Root directory to download sixs to, ie. [path]/sixs")
-def download_cli(**kwargs):
-    """\
-    Downloads 6S from https://github.com/ashiklom/isofit/releases/download/6sv-mirror/6sv-2.1.tar. Only HDF5 versions are supported at this time.
-
-    \b
-    Run `isofit download paths` to see default path locations.
-    There are two ways to specify output directory:
-        - `isofit --sixs /path/sixs download sixs`: Override the ini file. This will save the provided path for future reference.
-        - `isofit download sixs --output /path/sixs`: Temporarily set the output location. This will not be saved in the ini and may need to be manually set.
-    It is recommended to use the first style so the download path is remembered in the future.
-    """
-    download(**kwargs)
-
-
 def validate(path=None, debug=print, error=print, **_):
     """
     Validates a 6S installation
@@ -150,10 +134,36 @@ def validate(path=None, debug=print, error=print, **_):
     return True
 
 
-@cli.validate.command(name="sixs")
-@cli.path(help="Path to 6S installation")
-def validate_cli(**kwargs):
+def update(check=False, **kwargs):
+    """
+    Checks for an update and executes a new download if it is needed
+    Note: Not implemented for this module at this time
+
+    Parameters
+    ----------
+    check : bool, default=False
+        Just check if an update is available, do not download
+    **kwargs : dict
+        Additional key-word arguments to pass to download()
+    """
+    print("SixS does not support versioning at this time, no update to be found")
+
+
+@cli.download.command(name="sixs")
+@cli.path(help="Root directory to download sixs to, ie. [path]/sixs")
+@cli.validate
+def download_cli(validate_, **kwargs):
     """\
-    Validates a 6S installation
+    Downloads 6S from https://github.com/ashiklom/isofit/releases/download/6sv-mirror/6sv-2.1.tar. Only HDF5 versions are supported at this time.
+
+    \b
+    Run `isofit download paths` to see default path locations.
+    There are two ways to specify output directory:
+        - `isofit --sixs /path/sixs download sixs`: Override the ini file. This will save the provided path for future reference.
+        - `isofit download sixs --output /path/sixs`: Temporarily set the output location. This will not be saved in the ini and may need to be manually set.
+    It is recommended to use the first style so the download path is remembered in the future.
     """
-    validate(**kwargs)
+    if validate_:
+        validate(**kwargs)
+    else:
+        download(**kwargs)
diff --git a/isofit/data/cli/srtmnet.py b/isofit/data/cli/srtmnet.py
index dfd752ee..9d83635f 100644
--- a/isofit/data/cli/srtmnet.py
+++ b/isofit/data/cli/srtmnet.py
@@ -7,6 +7,7 @@ from pathlib import Path
 
 import click
 import requests
+from packaging.version import Version
 
 from isofit.data import env
 from isofit.data.download import cli, download_file, prepare_output
@@ -16,9 +17,25 @@ URL = "https://avng.jpl.nasa.gov/pub/PBrodrick/isofit/"
 
 def getVersion(version="latest"):
     """
-    Retrieves the available versions and verifies the requested version is valid
+    Retrieves the available versions and verifies the requested version is valid. Times
+    out after 10 seconds if the server is unavailable.
+
+    Parameters
+    ----------
+    version : str, default="latest"
+        Version of sRTMnet to pull
+
+    Returns
+    -------
+    str
+        Requested version
     """
-    get = requests.get(URL)
+    try:
+        get = requests.get(URL, timeout=10)
+    except requests.exceptions.Timeout:
+        print("sRTMnet server request timed out, cannot retrieve versions")
+        return
+
     versions = list(set(re.findall(r"sRTMnet_(v\d+)\.h5", get.text)))
     versions = sorted(versions, key=lambda v: int(v[1:]))
 
@@ -32,23 +49,25 @@ def getVersion(version="latest"):
         )
 
 
-def download(output=None, version="latest"):
+def download(path=None, tag="latest", overwrite=False):
     """
     Downloads sRTMnet from https://avng.jpl.nasa.gov/pub/PBrodrick/isofit/.
 
     Parameters
     ----------
     output: str | None
-        Path to output as. If None, defaults to the ini path.
-    version: str
-        Release tag to pull from the github.
+        Path to output as. If None, defaults to the ini path
+    tag: str
+        sRTMnet version to pull
+    overwrite : bool, default=False
+        Overwrite an existing installation
     """
-    if (version := getVersion(version)) is None:
+    if (version := getVersion(tag)) is None:
         return
 
     print(f"Downloading sRTMnet[{version}]")
 
-    output = prepare_output(output, env.srtmnet, isdir=True)
+    output = prepare_output(path, env.srtmnet, isdir=True, overwrite=overwrite)
     if not output:
         return
 
@@ -58,37 +77,19 @@ def download(output=None, version="latest"):
     file = f"sRTMnet_{version}.h5"
     download_file(f"{URL}/{file}", output / file)
 
+    env.changeKey("srtmnet.file", file)
+
     print("Retrieving aux.npz")
     file = f"sRTMnet_{version}_aux.npz"
     download_file(f"{URL}/{file}", output / file)
 
-    print(f"Done, now available at: {output}")
-
+    env.changeKey("srtmnet.aux", file)
+    env.save(diff_only=True)
 
-@cli.download.command(name="sRTMnet")
-@cli.output(help="Root directory to download sRTMnet to, ie. [path]/sRTMnet")
-@click.option(
-    "-v",
-    "--version",
-    default="latest",
-    help="Model version to download",
-    show_default=True,
-)
-def download_cli(**kwargs):
-    """\
-    Downloads sRTMnet from https://avng.jpl.nasa.gov/pub/PBrodrick/isofit/. Only HDF5 versions are supported at this time.
-
-    \b
-    Run `isofit download paths` to see default path locations.
-    There are two ways to specify output directory:
-        - `isofit --srtmnet /path/sRTMnet download sRTMnet`: Override the ini file. This will save the provided path for future reference.
-        - `isofit download sRTMnet --output /path/sRTMnet`: Temporarily set the output location. This will not be saved in the ini and may need to be manually set.
-    It is recommended to use the first style so the download path is remembered in the future.
-    """
-    download(**kwargs)
+    print(f"Done, now available at: {output}")
 
 
-def validate(path=None, debug=print, error=print, **_):
+def validate(path=None, checkUpdate=True, debug=print, error=print, **_):
     """
     Validates an sRTMnet installation
 
@@ -96,6 +97,8 @@ def validate(path=None, debug=print, error=print, **_):
     ----------
     path : str, default=None
         Path to verify. If None, defaults to the ini path
+    checkUpdate : bool, default=True
+        Checks for updates if the path is valid
     debug : function, default=print
         Print function to use for debug messages, eg. logging.debug
     error : function, default=print
@@ -129,13 +132,190 @@ def validate(path=None, debug=print, error=print, **_):
         return False
 
     debug("Path is valid")
+
+    if checkUpdate:
+        checkForUpdate(path, debug=debug, error=error)
+
     return True
 
 
-@cli.validate.command(name="sRTMnet")
-@cli.path(help="Path to sRTMnet installation")
-def validate_cli(**kwargs):
+def detectInstalled(path: str = None):
+    """
+    Attempt to detect a currently installed sRTMnet model and, if present, save that
+    information back to the ini for future reference
+
+    Parameters
+    ----------
+    path : str, default=None
+        Path to check. If None, defaults to the ini path
+    """
+    path = None
+
+    if path is None:
+        path = env.srtmnet
+
+    path = Path(path)
+
+    if path.exists():
+        try:
+            # Just retrieve the first of each extension
+            # This may be inaccurate if multiple installs are in one directory
+            env.changeKey("srtmnet.file", next(path.glob("*.h5")).name)
+            env.changeKey("srtmnet.aux", next(path.glob("*.npz")).name)
+            env.save()
+        except:
+            pass
+
+
+def compare(file, name, version, error=print):
+    """
+    Compares an existing sRTMnet file against some other provided version
+
+    Parameters
+    ----------
+    file : str
+        sRTMnet model or aux file name
+    name : str
+        File type name for reporting with
+    version : packaging.version.Version, default=None
+        Version to compare against. If not given, retrieves the latest version from the
+        server
+    error : function, default=print
+        Print function to use for error messages, eg. logging.error
+
+    Returns
+    -------
+    bool
+        True if the current version is not the latest, False otherwise
+    """
+    if version is None:
+        if not (latest := getVersion(tag)):
+            error("Failed to retrieve latest version, try again later")
+            return False
+
+        latest = Version(latest)
+
+    if find := re.findall(r"(v\d+)", file):
+        current = Version(find[0])
+    else:
+        error(f"Version could not be parsed from the path for {name}")
+        return True
+
+    if current < version:
+        error(
+            f"The sRTMnet {name} is out of date. The latest is v{version}, currently installed is v{current}"
+        )
+        return True
+
+    return False
+
+
+def checkForUpdate(path=None, tag="latest", debug=print, error=print, **_):
+    """
+    Checks the installed version against the latest release
+
+    Parameters
+    ----------
+    path : str, default=None
+        Path to update. If None, defaults to the ini path
+    debug : function, default=print
+        Print function to use for debug messages, eg. logging.debug
+    error : function, default=print
+        Print function to use for error messages, eg. logging.error
+    **_ : dict
+        Ignores unused params that may be used by other validate functions. This is to
+        maintain compatibility with other functions
+
+    Returns
+    -------
+    bool
+        True if there is a version update, else False
+    """
+    missing = False
+    if not env["srtmnet.file"]:
+        error("sRTMnet file is not set in the ini, version unknown")
+        missing = True
+
+    if not env["srtmnet.aux"]:
+        error("sRTMnet aux is not set in the ini, version unknown")
+        missing = True
+
+    if missing:
+        debug(
+            "Attempting to detect if a model and aux are already installed but not set in the ini"
+        )
+        detectInstalled(path)
+
+    if path is None:
+        # If the paths do not exist, env.path() will report that
+        model = env.path("srtmnet", key="srtmnet.file")
+        aux = env.path("srtmnet", key="srtmnet.aux")
+
+        if not model.exists() or not aux.exists():
+            error("Download the above via `isofit download sRTMnet --update`")
+            return True
+    else:
+        path = Path(path)
+        model = path / env["srtmnet.file"]
+        aux = path / env["srtmnet.aux"]
+
+    debug(f"Checking for updates for sRTMnet on path: {model}")
+
+    if not (latest := getVersion(tag)):
+        error("Failed to retrieve latest version, try again later")
+        return False
+
+    latest = Version(latest)
+    model = compare(model.name, "model", latest, error)
+    aux = compare(aux.name, "aux", latest, error)
+
+    if model or aux:
+        error("Please update via `isofit download sRTMnet --update`")
+        return True
+
+    debug("Path is up to date")
+
+    return False
+
+
+def update(check=False, **kwargs):
+    """
+    Checks for an update and executes a new download if it is needed
+
+    Parameters
+    ----------
+    check : bool, default=False
+        Just check if an update is available, do not download
+    **kwargs : dict
+        Additional key-word arguments to pass to download()
+    """
+    kwargs["overwrite"] = True
+    if checkForUpdate(**kwargs) and not check:
+        kwargs.get("debug", print)("Executing update")
+        download(**kwargs)
+
+
+@cli.download.command(name="sRTMnet")
+@cli.path(help="Root directory to download sRTMnet to, ie. [path]/sRTMnet")
+@cli.tag
+@cli.overwrite
+@cli.update
+@cli.check
+@cli.validate
+def download_cli(update_, check, validate_, **kwargs):
     """\
-    Validates an sRTMnet installation
+    Downloads sRTMnet from https://avng.jpl.nasa.gov/pub/PBrodrick/isofit/. Only HDF5 versions are supported at this time.
+
+    \b
+    Run `isofit download paths` to see default path locations.
+    There are two ways to specify output directory:
+        - `isofit --srtmnet /path/sRTMnet download sRTMnet`: Override the ini file. This will save the provided path for future reference.
+        - `isofit download sRTMnet --output /path/sRTMnet`: Temporarily set the output location. This will not be saved in the ini and may need to be manually set.
+    It is recommended to use the first style so the download path is remembered in the future.
     """
-    validate(**kwargs)
+    if update_:
+        update(check, **kwargs)
+    elif validate_:
+        validate(**kwargs)
+    else:
+        download(**kwargs)
diff --git a/isofit/data/download.py b/isofit/data/download.py
index bed3dc3d..934f1ff9 100644
--- a/isofit/data/download.py
+++ b/isofit/data/download.py
@@ -5,6 +5,7 @@ Implements the `isofit download` subcommands
 import io
 import json
 import os
+import shutil
 import tarfile
 import urllib.request
 from email.message import EmailMessage
@@ -107,28 +108,33 @@ def unzip(file, path=None, rename=None, overwrite=False, cleanup=True):
 
     Returns
     -------
-    outp : str
+    dst : str
         The extracted output path
     """
-    path = path or os.path.dirname(file)
+    path = Path(path or os.path.dirname(file))
 
     with ZipFile(file) as z:
         name = z.namelist()[0]
 
         # Verify the output target doesn't exist
-        outp = f"{path}/{rename or name}"
-        if os.path.exists(outp) and not overwrite:
-            raise FileExistsError(outp)
+        dst = path / (rename or name)
+        if dst.exists() and not overwrite:
+            raise FileExistsError(dst)
 
         z.extractall(path)
 
+    src = Path(path) / name
     if rename:
-        os.rename(f"{path}/{name}", outp)
+        if dst.exists():
+            shutil.copytree(src, dst, dirs_exist_ok=True)
+            shutil.rmtree(src)
+        else:
+            shutil.move(src, dst)
 
     if cleanup:
         os.remove(file)
 
-    return outp
+    return dst
 
 
 def untar(file, output):
@@ -155,7 +161,7 @@ def untar(file, output):
     return output
 
 
-def prepare_output(output, default, isdir=False):
+def prepare_output(output, default, isdir=False, overwrite=False):
     """
     Prepares the output path by ensuring the parents exist and itself doesn't presently exist.
 
@@ -167,6 +173,8 @@ def prepare_output(output, default, isdir=False):
         Default path defined by the ini file
     isdir : bool, default=False
         This is supposed to be a directory
+    overwrite : bool, default=False
+        Ignore if the output already exists
     """
     if not output:
         output = default
@@ -175,8 +183,10 @@ def prepare_output(output, default, isdir=False):
 
     print(f"Output as: {output}")
 
-    if output.exists():
-        print(f"Path already exists, please remove it if you would like to redownload")
+    if not overwrite and output.exists():
+        print(
+            f"Path already exists, please remove it or set the overwrite flag if you would like to redownload"
+        )
         return
 
     try:
@@ -202,14 +212,6 @@ def cli_download():
     pass
 
 
-@click.group("validate", invoke_without_command=True)
-def cli_validate():
-    """\
-    Validate extra ISOFIT files and installations
-    """
-    pass
-
-
 # Shared click options
 
 cli = SimpleNamespace(
@@ -218,8 +220,40 @@ cli = SimpleNamespace(
     tag=click.option(
         "-t", "--tag", default=f"latest", help="Release tag to pull", show_default=True
     ),
-    validate=cli_validate,
+    overwrite=click.option(
+        "--overwrite",
+        is_flag=True,
+        default=False,
+        help="Overwrite any existing installation",
+        show_default=True,
+    ),
     path=partial(click.option, "-p", "--path"),
+    update=click.option(
+        "-u",
+        "--update",
+        "update_",
+        is_flag=True,
+        default=False,
+        help="Update the installation",
+        show_default=True,
+    ),
+    check=click.option(
+        "-c",
+        "--check",
+        is_flag=True,
+        default=False,
+        help="Only check for updates",
+        show_default=True,
+    ),
+    validate=click.option(
+        "-v",
+        "--validate",
+        "validate_",
+        is_flag=True,
+        default=False,
+        help="Validate the installation",
+        show_default=True,
+    ),
 )
 
 # Subcommands
diff --git a/isofit/data/ini.py b/isofit/data/ini.py
index 32e7349d..e82077d6 100644
--- a/isofit/data/ini.py
+++ b/isofit/data/ini.py
@@ -2,27 +2,24 @@
 ISOFIT environment module
 """
 
+from __future__ import annotations
+
 import logging
 from configparser import ConfigParser
 from pathlib import Path
-from typing import Iterable, List, Optional, Tuple
 
 Logger = logging.getLogger(__file__)
 
 
 class Ini:
-    base: Path = Path.home() / ".isofit/"
-    dirs: List[str] = ["data", "examples", "imagecube", "srtmnet", "sixs", "modtran"]
-    config: ConfigParser = ConfigParser()
-    section: str = "DEFAULT"
+    # Default directories to expect
+    _dirs: List[str] = ["data", "examples", "imagecube", "srtmnet", "sixs", "modtran"]
 
-    def __init__(self):
-        self.ini = self.base / "isofit.ini"
-
-        # Initialize ConfigParser with default values
-        for key in self.dirs:
-            self.changePath(key, self.base / key)
+    # Additional keys with default values
+    _keys: Dict[str, str] = {"srtmnet.file": "", "srtmnet.aux": ""}
 
+    def __init__(self):
+        self.reset()
         self.load()
 
     def __getattr__(self, key: str) -> Optional[str]:
@@ -60,6 +57,11 @@ class Ini:
     def __iter__(self) -> Iterable:
         return iter(self.config[self.section])
 
+    def __repr__(self):
+        return f"[{self.section}]\n" + "\n".join(
+            [f"{key} = {value}" for key, value in self.items()]
+        )
+
     def keys(self) -> Iterable[str]:
         return iter(self.config[self.section])
 
@@ -81,9 +83,28 @@ class Ini:
         self.base = Path(base)
 
         # Re-initialize
-        for key in self.dirs:
+        for key in self._dirs:
             self.changePath(key, self.base / key)
 
+    def changeKey(self, key: str, value: str = ""):
+        """
+        Change the value associated with the specified key in the CONFIG[SECTION].
+
+        Parameters
+        ----------
+        key : str, dict
+            Key to set. Alternatively, can be a dict to iterate over setting multiple
+            keys at once.
+        value : str, default=""
+            The new value to associate with the key.
+        """
+        if isinstance(key, dict):
+            for k, v in key.items():
+                self.changeKey(k, v)
+            return
+
+        self.config[self.section][key] = str(value)
+
     def changeSection(self, section: str) -> None:
         """
         Changes the working section of the config.
@@ -133,7 +154,7 @@ class Ini:
             self.config.read(self.ini)
 
             # Retrieve the absolute path
-            for key in self.dirs:
+            for key in self._dirs:
                 self.changePath(key, self[key])
 
             Logger.info(f"Loaded ini from: {self.ini}")
@@ -177,7 +198,7 @@ class Ini:
             except:
                 Logger.exception(f"Failed to dump ini to file: {self.ini}")
 
-    def path(self, dir: str, *path: list) -> Path:
+    def path(self, dir: str, *path: List[str], key: str = None) -> Path:
         """
         Retrieves a path under one of the env directories and validates the path exists.
 
@@ -187,16 +208,36 @@ class Ini:
             One of the env directories, eg. "data", "examples"
         *path : List[str]
             Path to a file under the `dir`
+        key : str, default=None
+            Optional key value to append to the resolved path. Assumes the path is a
+            directory and the key will be a file name
 
         Returns
         -------
         pathlib.Path
             Validated full path
+
+        Examples
+        --------
+        >>> from isofit.data import env
+        >>> env.load()
+        >>> env.path("data")
+        ~/.isofit/data
+        >>> env.path("examples", "20171108_Pasadena", "configs", "ang20171108t184227_surface.json")
+        ~/.isofit/examples/20171108_Pasadena/configs/ang20171108t184227_surface.json
+        >>> env.path("srtmnet", key="srtmnet.file")
+        ~/.isofit/srtmnet/sRTMnet_v120.h5
+        >>> env.path("srtmnet", key="srtmnet.aux")
+        ~/.isofit/srtmnet/sRTMnet_v120_aux.npz
         """
         self.validate([dir], debug=Logger.debug, error=Logger.error)
 
         path = Path(*[self[dir], *path]).resolve()
 
+        # Retrieve the value stored for the given key if it's set
+        if key and self[key]:
+            path /= self[key]
+
         if not path.exists():
             Logger.error(
                 f"The following path does not exist, please verify your installation environment: {path}"
@@ -204,6 +245,26 @@ class Ini:
 
         return path
 
+    def reset(self, save: bool = False):
+        """
+        Resets the object to the defaults defined by ISOFIT
+
+        Parameters
+        ----------
+        save : bool, default=False
+            Saves the reset to the default ini file: ~/.isofit/isofit.ini
+        """
+        self.config = ConfigParser()
+        self.section = "DEFAULT"
+
+        self.changeBase(Path.home() / ".isofit/")
+        self.changeKey(self._keys)
+
+        self.ini = self.base / "isofit.ini"
+
+        if save:
+            self.save()
+
     @staticmethod
     def validate(keys: List) -> bool:
         """
diff --git a/isofit/radiative_transfer/luts.py b/isofit/radiative_transfer/luts.py
index e181c672..1ef04a68 100644
--- a/isofit/radiative_transfer/luts.py
+++ b/isofit/radiative_transfer/luts.py
@@ -649,31 +649,21 @@ def load(
 
     # Handle NaNs in the LUT. Keep as is (if all NaN). Set to 0 (if partial NaN)
     Logger.debug("Attempting to detect NaNs")
-    all_nan = [bool(nans.data) for name, nans in ds.isnull().all().items()]
-    any_nan = [bool(nans.data) for name, nans in ds.isnull().any().items()]
 
-    # Get the index of lut elements that have at least 1 nan, but not all
-    partial_nan = np.argwhere(np.sum(np.vstack([all_nan, any_nan]), axis=0) == 1)
-    all_nan = np.argwhere(np.sum(np.vstack([all_nan, any_nan]), axis=0) == 2)
+    for key, data in ds.items():
+        nans = data.isnull()
+        if nans.any():
+            if nans.all():
+                Logger.warning(f"{key} is fully NaN, leaving as-is")
+                continue
 
-    # Get the names of these two categories
-    all_names = [list(ds.keys())[int(i[0])] for i in all_nan]
-    partial_names = [list(ds.keys())[int(i[0])] for i in partial_nan]
+            count = nans.sum().data
+            total = data.count().data
 
-    # Set the partial NaN to 0
-    for name in partial_names:
-        ds[name].data = np.nan_to_num(ds[name].data, nan=0.0)
-
-    # Logging
-    if len(all_names):
-        Logger.warning(
-            f"Detected all NaNs in the following LUT variables. Keeping values at NaN: {all_names}"
-        )
-
-    if len(partial_names):
-        Logger.warning(
-            f"Detected partial NaNs in the following LUT variables. Setting to 0: {partial_names}"
-        )
+            Logger.warning(
+                f"{key} is partially NaN ({count}/{total}, {count/total:.2%}), replacing with 0s"
+            )
+            ds[key] = data.fillna(0)
 
     return ds
 


Test patch:
diff --git a/isofit/test/test_cli.py b/isofit/test/test_cli.py
index d6176337..193fb21c 100644
--- a/isofit/test/test_cli.py
+++ b/isofit/test/test_cli.py
@@ -90,7 +90,7 @@ def test_apply_oe(files, args, surface):
     ray.shutdown()
     sleep(120)
 
-    args[3] = env.path("srtmnet", "sRTMnet_v120.h5")
+    args[3] = env.path("srtmnet", key="srtmnet.file")
     arguments = ["apply_oe", *files, *args, "--surface_path", surface]
 
     # Passing non-string arguments to click is not allowed.
diff --git a/isofit/test/test_cli_machinery.py b/isofit/test/test_cli_machinery.py
index c86b717f..019d27c0 100644
--- a/isofit/test/test_cli_machinery.py
+++ b/isofit/test/test_cli_machinery.py
@@ -64,7 +64,7 @@ def test_subcommand_registration(executable):
         assert cmd in stdout_txt
 
     # Check to make sure the right number of subcommands are registered
-    assert subcommand_count == 14
+    assert subcommand_count == 13
 
 
 def test_version():
diff --git a/isofit/test/test_env.py b/isofit/test/test_env.py
index b47c40a1..50197b9d 100644
--- a/isofit/test/test_env.py
+++ b/isofit/test/test_env.py
@@ -1,54 +1,68 @@
-from configparser import ConfigParser
-from pathlib import Path
-from unittest.mock import mock_open, patch
-
 import pytest
 
 from isofit.data import env
 
 
-def test_getattr_existing_key():
-    assert env.data == env.config["DEFAULT"]["data"]
+@pytest.fixture(scope="session")
+def changedDirs():
+    env.reset()
+
+    # Changed dirs
+    dirs = {key: f"/abc/{key}" for key in env._dirs}
+
+    # Default keys
+    keys = {key: env[key] for key in env._keys}
+
+    return {**dirs, **keys}
+
+
+@pytest.fixture(scope="session")
+def changedKeys():
+    env.reset()
+
+    # Default dirs
+    dirs = {key: env[key] for key in env._dirs}
+
+    # Changed keys
+    keys = {key: f"/xyz/{key}" for key in env._keys}
+
+    return {**dirs, **keys}
+
+
+@pytest.fixture(scope="session")
+def changedBoth(changedDirs, changedKeys):
+    both = {}
+
+    for key in env._dirs:
+        both[key] = changedDirs[key]
+
+    for key in env._keys:
+        both[key] = changedKeys[key]
 
+    return both
 
-def test_changePath():
-    expected = {}
-    for key in env.dirs:
-        expected[key] = (val := f"/test/{key}")
-        env.changePath(key, val)
 
-    assert env.config["DEFAULT"] == expected
+def test_changePath(changedDirs):
+    env.reset()
 
+    for key in env._dirs:
+        env.changePath(key, changedDirs[key])
 
-@patch("builtins.open")
-def test_loadEnv_file_exists(mock_open):
-    path = "test.ini"
-    data = {key: f"/test/{key}" for key in env.dirs}
+    assert dict(env) == changedDirs
 
-    with patch("pathlib.Path.exists", return_value=True), patch.object(
-        env.config, "read", return_value=None
-    ):
-        env.load(path)
 
-    assert dict(env.config["DEFAULT"]) == data
-    assert env.ini == Path(path)
+def test_changeKey(changedKeys):
+    env.reset()
 
+    for key in env._keys:
+        env.changeKey(key, changedKeys[key])
 
-@patch("builtins.open")
-def test_loadEnv_file_not_exists(mock_open):
-    path = "test.ini"
-    with patch("pathlib.Path.exists", return_value=False):
-        env.load(path)
+    assert dict(env) == changedKeys
 
-    # load changes INI for the remainder of the session
-    assert env.ini == Path(path)
 
+def test_changeBase(changedDirs):
+    env.reset()
 
-@patch("builtins.open")
-def test_saveEnv(mock_open):
-    path = "test.ini"
-    with patch.object(env.config, "write"), patch("pathlib.Path.mkdir"):
-        env.save(path)
+    env.changeBase("/abc")
 
-        # save changes INI for the remainder of the session
-        assert env.ini == Path(path)
+    assert dict(env) == changedDirs