Repository: dsavransky/EXOSIMS (language: python)

Issue:
no bpgs fits files in TargetList/dat_uvk
Using the balmer branch for beyond visible spectral coverage, here is the run and traceback:
2D completeness array stored in '/home/rhonda/.EXOSIMS/cache_ATSA20200921/EarthTwinHabZone1SDETPlanetPhysicalModelBrownCompleteness76a502c79a5afc084e90f6e1d4df46a1.comp'
Reference mag for star # 0  is  6.341  in band  1630.0 nm
Spec match for star # 0  is  K2V
Reference mag for star # 1  is  6.113  in band  1630.0 nm
Spec match for star # 1  is  K5V
Reference mag for star # 2  is  4.179  in band  1630.0 nm
Spec match for star # 2  is  G2V
Reference mag for star # 3  is  6.027  in band  1630.0 nm
Spec match for star # 3  is  G8V
Reference mag for star # 4  is  5.885  in band  1630.0 nm
Spec match for star # 4  is  K5V
Reference mag for star # 5  is  6.816  in band  1630.0 nm
Spec match for star # 5  is  K2V
Reference mag for star # 6  is  4.42  in band  1630.0 nm
Spec match for star # 6  is  G5V
Reference mag for star # 7  is  4.646  in band  1630.0 nm
Spec match for star # 7  is  F6V
---------------------------------------------------------------------------
FileNotFoundError                         Traceback (most recent call last)
<ipython-input-5-43df157b695b> in <module>
----> 1 sim = EXOSIMS.MissionSim.MissionSim(scriptfile,nopar=True, seed=777)

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/MissionSim.py in __init__(self, scriptfile, nopar, verbose, **specs)
    146         #create a surveysimulation object (triggering init of everything else)
    147         self.SurveySimulation = get_module(specs['modules']['SurveySimulation'],
--> 148                 'SurveySimulation')(**specs)
    149 
    150         # collect sub-initializations

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/SurveySimulation/linearJScheduler_orbitChar.py in __init__(self, coeffs, revisit_wait, n_det_remove, n_det_min, max_successful_dets, det_only, **specs)
     43                  n_det_min=3, max_successful_dets=4, det_only=False, **specs):
     44 
---> 45         SurveySimulation.__init__(self, **specs)
     46         TL = self.TargetList
     47         OS = self.OpticalSystem

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/Prototypes/SurveySimulation.py in __init__(self, scriptfile, ntFlux, nVisitsMax, charMargin, WAint, dMagint, dt_max, scaleWAdMag, record_counts_path, nokoMap, cachedir, defaultAddExoplanetObsTime, dMagLim_offset, find_known_RV, **specs)
    174             # import desired module names (prototype or specific)
    175             self.SimulatedUniverse = get_module(specs['modules']['SimulatedUniverse'],
--> 176                     'SimulatedUniverse')(**specs)
    177             self.Observatory = get_module(specs['modules']['Observatory'],
    178                     'Observatory')(**specs)

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/SimulatedUniverse/DulzPlavchanUniverseEarthsOnly.py in __init__(self, **specs)
     10     def __init__(self, **specs):
     11 
---> 12         SimulatedUniverse.__init__(self, **specs)
     13 
     14     def gen_physical_properties(self, **specs):

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/Prototypes/SimulatedUniverse.py in __init__(self, fixedPlanPerStar, Min, cachedir, lucky_planets, commonSystemInclinations, **specs)
    140         # import TargetList class
    141         self.TargetList = get_module(specs['modules']['TargetList'],
--> 142                 'TargetList')(**specs)
    143 
    144         # bring inherited class objects to top level of Simulated Universe

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/Prototypes/TargetList.py in __init__(self, missionStart, staticStars, keepStarCatalog, fillPhotometry, explainFiltering, filterBinaries, filterSubM, cachedir, filter_for_char, earths_only, getKnownPlanets, **specs)
    229 
    230         # now populate and filter the list
--> 231         self.populate_target_list(**specs)
    232         # generate any completeness update data needed
    233         self.Completeness.gen_update(self)

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/Prototypes/TargetList.py in populate_target_list(self, popStars, **specs)
    339             self.comp0 = Comp.target_completeness(self, calc_char_comp0=True)
    340             # populate minimum integration time values
--> 341             self.tint0 = OS.calc_minintTime(self, use_char=True, mode=char_modes[0])
    342             for mode in char_modes[1:]:
    343                 self.tint0 += OS.calc_minintTime(self, use_char=True, mode=mode)

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/Prototypes/OpticalSystem.py in calc_minintTime(self, TL, use_char, mode)
    911 
    912         # calculate minimum integration time
--> 913         minintTime = self.calc_intTime(TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None)
    914 
    915         return minintTime

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/OpticalSystem/Nemati.py in calc_intTime(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK)
     54 
     55         # electron counts
---> 56         C_p, C_b, C_sp = self.Cp_Cb_Csp(TL, sInds, fZ, fEZ, dMag, WA, mode, TK=TK)
     57 
     58         # get SNR threshold

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/Prototypes/OpticalSystem.py in Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, returnExtra, TK)
    743 
    744         # get star magnitude, F0
--> 745         F_0, mag = TL.starF0(sInds, mode)
    746 
    747         C_F0 = F_0*self.pupilArea*deltaLam*inst['QE'](lam)*attenuation

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/Prototypes/TargetList.py in starF0(self, sInds, mode)
   1060             self.magdict[mode['hex']] = np.full(self.nStars, np.nan)
   1061             for j in sInds:
-> 1062                 self.F0dict[mode['hex']][j], self.magdict[mode['hex']][j] = self.F0(mode['BW'], mode['lam'], j, spec=self.Spec[j])
   1063 
   1064         return(self.F0dict[mode['hex']][sInds], self.magdict[mode['hex']][sInds])

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/Prototypes/TargetList.py in F0(self, BW, lam, sInd, spec, teff)
    513             # Open corresponding spectrum
    514             with fits.open(os.path.join(self.specdatapath,
--> 515                            self.specindex[specmatch])) as hdulist:
    516                 sdat = hdulist[1].data
    517 

/usr/local/anaconda3/envs/cornell/lib/python3.7/site-packages/astropy/io/fits/hdu/hdulist.py in fitsopen(name, mode, memmap, save_backup, cache, lazy_load_hdus, **kwargs)
    156 
    157     return HDUList.fromfile(name, mode, memmap, save_backup, cache,
--> 158                             lazy_load_hdus, **kwargs)
    159 
    160 

/usr/local/anaconda3/envs/cornell/lib/python3.7/site-packages/astropy/io/fits/hdu/hdulist.py in fromfile(cls, fileobj, mode, memmap, save_backup, cache, lazy_load_hdus, **kwargs)
    395         return cls._readfrom(fileobj=fileobj, mode=mode, memmap=memmap,
    396                              save_backup=save_backup, cache=cache,
--> 397                              lazy_load_hdus=lazy_load_hdus, **kwargs)
    398 
    399     @classmethod

/usr/local/anaconda3/envs/cornell/lib/python3.7/site-packages/astropy/io/fits/hdu/hdulist.py in _readfrom(cls, fileobj, data, mode, memmap, save_backup, cache, lazy_load_hdus, **kwargs)
   1044             if not isinstance(fileobj, _File):
   1045                 # instantiate a FITS file object (ffo)
-> 1046                 fileobj = _File(fileobj, mode=mode, memmap=memmap, cache=cache)
   1047             # The Astropy mode is determined by the _File initializer if the
   1048             # supplied mode was None

/usr/local/anaconda3/envs/cornell/lib/python3.7/site-packages/astropy/utils/decorators.py in wrapper(*args, **kwargs)
    519                             kwargs[new_name[i]] = value
    520 
--> 521             return function(*args, **kwargs)
    522 
    523         return wrapper

/usr/local/anaconda3/envs/cornell/lib/python3.7/site-packages/astropy/io/fits/file.py in __init__(self, fileobj, mode, memmap, overwrite, cache)
    191             self._open_fileobj(fileobj, mode, overwrite)
    192         elif isinstance(fileobj, str):
--> 193             self._open_filename(fileobj, mode, overwrite)
    194         else:
    195             self._open_filelike(fileobj, mode, overwrite)

/usr/local/anaconda3/envs/cornell/lib/python3.7/site-packages/astropy/io/fits/file.py in _open_filename(self, filename, mode, overwrite)
    572 
    573         if not self._try_read_compressed(self.name, magic, mode, ext=ext):
--> 574             self._file = fileobj_open(self.name, IO_FITS_MODES[mode])
    575             self.close_on_error = True
    576 

/usr/local/anaconda3/envs/cornell/lib/python3.7/site-packages/astropy/io/fits/util.py in fileobj_open(filename, mode)
    394     """
    395 
--> 396     return open(filename, mode, buffering=0)
    397 
    398 

FileNotFoundError: [Errno 2] No such file or directory: '/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/TargetList/dat_uvk/bpgs_33.fits'


**To Reproduce**
this occured on instantiaion of the sim object in ipython:
scriptfile = '/proj/exep/rhonda/Sandbox/HabEx/Scripts/ATSA_LJSoC_DulzE_TV3_base_IR1_a.12b.5c0d0.3e0f.05_2
   ...: 0210423.json'                                                                                            

In [5]: sim = EXOSIMS.MissionSim.MissionSim(scriptfile,nopar=True, seed=777)  


**JSON script:**
The relevant part of the script is:
    {
      "name": "Occulter_testcase",
      "occulter": true,

      "lam": 1560,
      "IWA": 0.149,
      "OWA": 16.589,
      "ohTime": 0.33,
      "BW": 1.08,
      "optics": 1,
      "core_platescale": 0.1667,
      "occ_trans": "/proj/exep/rhonda/HabEx/Starshade/TV3_occ_trans_asec_650_49906.fits",
      "occulterDiameter": 72,
      "NocculterDistances" :1,
      "occulterDistances": [
        {
        "occulterDistance": 49906,
        "occulterRedEdge": 2400,
        "occulterBlueEdge": 720
        }
      ],
      "core_thruput": "/proj/exep/rhonda/HabEx/Starshade/TV3_core_thruput_asec_650_49906.fits",
      "core_mean_intensity": "/proj/exep/rhonda/HabEx/Starshade/TV3_core_mean_intensity_asec_650_49906.fits",
      "koAngles_Small": [1,180],
      "koAngles_Sun": [40,83],
      "koAngles_Moon": [1,180],
      "koAngles_Earth": [1,180]
   }
 ],
      "observingModes": [
    {
      "instName": "imagingEMCCD",
      "systName": "Occulter_testcase",
      "detection": 1,
      "lam": 1560,
      "SNR": 7
    },
    {
      "instName": "spectroEMCCD",
      "systName": "Occulter_testcase",
      "SNR": 10
    }
  ],
  "modules": {
    "PlanetPopulation": "AlbedoByRadiusDulzPlavchan",
    "StarCatalog": "EXOCAT1",
    "OpticalSystem": "Nemati",
    "ZodiacalLight": "Mennesson",
    "BackgroundSources": "GalaxiesFaintStars",
    "PlanetPhysicalModel": "Forecaster",
    "Observatory": "WFIRSTObservatoryL2",
    "TimeKeeping": " ",
    "PostProcessing": " ",
    "Completeness": "BrownCompleteness",
    "TargetList": "",
    "SimulatedUniverse": "DulzPlavchanUniverseEarthsOnly",
    "SurveySimulation": "linearJScheduler_orbitChar",
    "SurveyEnsemble": "EXOSIMS_local.IPClusterEnsembleJPL2"
  },
   "completeness_specs":{
     "modules":{
       "PlanetPopulation": "EarthTwinHabZone1SDET", 
       "PlanetPhysicalModel": " "
   }
  }


**EXOSIMS version:**
Balmer2020

**Additional context**
I remember many libraries being discussed.  I will rumage through old emails to see if I can find the libraries with bpgs files and download them.

Need to switch to synphot for star photometry calculations
**Is your feature request related to a problem? Please describe.**
Our stellar photometry calculations have gotten too complex and hard to check/debug.  We're also needlessly reinventing the wheel on a lot of this.  

**Describe the solution you'd like**
synphot (https://synphot.readthedocs.io/) is a mature, stable package, supported by STScI, which does exactly what we need.

**Additional context**
This will involve wrapping synphot file management into the init of TargetList (replacing current handling of the Pickles library), rewriting starMag and starF0 and F0 methods, and all of their calls.
no bpgs fits files in TargetList/dat_uvk
Using the balmer branch for beyond visible spectral coverage, here is the run and traceback:
2D completeness array stored in '/home/rhonda/.EXOSIMS/cache_ATSA20200921/EarthTwinHabZone1SDETPlanetPhysicalModelBrownCompleteness76a502c79a5afc084e90f6e1d4df46a1.comp'
Reference mag for star # 0  is  6.341  in band  1630.0 nm
Spec match for star # 0  is  K2V
Reference mag for star # 1  is  6.113  in band  1630.0 nm
Spec match for star # 1  is  K5V
Reference mag for star # 2  is  4.179  in band  1630.0 nm
Spec match for star # 2  is  G2V
Reference mag for star # 3  is  6.027  in band  1630.0 nm
Spec match for star # 3  is  G8V
Reference mag for star # 4  is  5.885  in band  1630.0 nm
Spec match for star # 4  is  K5V
Reference mag for star # 5  is  6.816  in band  1630.0 nm
Spec match for star # 5  is  K2V
Reference mag for star # 6  is  4.42  in band  1630.0 nm
Spec match for star # 6  is  G5V
Reference mag for star # 7  is  4.646  in band  1630.0 nm
Spec match for star # 7  is  F6V
---------------------------------------------------------------------------
FileNotFoundError                         Traceback (most recent call last)
<ipython-input-5-43df157b695b> in <module>
----> 1 sim = EXOSIMS.MissionSim.MissionSim(scriptfile,nopar=True, seed=777)

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/MissionSim.py in __init__(self, scriptfile, nopar, verbose, **specs)
    146         #create a surveysimulation object (triggering init of everything else)
    147         self.SurveySimulation = get_module(specs['modules']['SurveySimulation'],
--> 148                 'SurveySimulation')(**specs)
    149 
    150         # collect sub-initializations

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/SurveySimulation/linearJScheduler_orbitChar.py in __init__(self, coeffs, revisit_wait, n_det_remove, n_det_min, max_successful_dets, det_only, **specs)
     43                  n_det_min=3, max_successful_dets=4, det_only=False, **specs):
     44 
---> 45         SurveySimulation.__init__(self, **specs)
     46         TL = self.TargetList
     47         OS = self.OpticalSystem

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/Prototypes/SurveySimulation.py in __init__(self, scriptfile, ntFlux, nVisitsMax, charMargin, WAint, dMagint, dt_max, scaleWAdMag, record_counts_path, nokoMap, cachedir, defaultAddExoplanetObsTime, dMagLim_offset, find_known_RV, **specs)
    174             # import desired module names (prototype or specific)
    175             self.SimulatedUniverse = get_module(specs['modules']['SimulatedUniverse'],
--> 176                     'SimulatedUniverse')(**specs)
    177             self.Observatory = get_module(specs['modules']['Observatory'],
    178                     'Observatory')(**specs)

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/SimulatedUniverse/DulzPlavchanUniverseEarthsOnly.py in __init__(self, **specs)
     10     def __init__(self, **specs):
     11 
---> 12         SimulatedUniverse.__init__(self, **specs)
     13 
     14     def gen_physical_properties(self, **specs):

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/Prototypes/SimulatedUniverse.py in __init__(self, fixedPlanPerStar, Min, cachedir, lucky_planets, commonSystemInclinations, **specs)
    140         # import TargetList class
    141         self.TargetList = get_module(specs['modules']['TargetList'],
--> 142                 'TargetList')(**specs)
    143 
    144         # bring inherited class objects to top level of Simulated Universe

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/Prototypes/TargetList.py in __init__(self, missionStart, staticStars, keepStarCatalog, fillPhotometry, explainFiltering, filterBinaries, filterSubM, cachedir, filter_for_char, earths_only, getKnownPlanets, **specs)
    229 
    230         # now populate and filter the list
--> 231         self.populate_target_list(**specs)
    232         # generate any completeness update data needed
    233         self.Completeness.gen_update(self)

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/Prototypes/TargetList.py in populate_target_list(self, popStars, **specs)
    339             self.comp0 = Comp.target_completeness(self, calc_char_comp0=True)
    340             # populate minimum integration time values
--> 341             self.tint0 = OS.calc_minintTime(self, use_char=True, mode=char_modes[0])
    342             for mode in char_modes[1:]:
    343                 self.tint0 += OS.calc_minintTime(self, use_char=True, mode=mode)

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/Prototypes/OpticalSystem.py in calc_minintTime(self, TL, use_char, mode)
    911 
    912         # calculate minimum integration time
--> 913         minintTime = self.calc_intTime(TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None)
    914 
    915         return minintTime

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/OpticalSystem/Nemati.py in calc_intTime(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK)
     54 
     55         # electron counts
---> 56         C_p, C_b, C_sp = self.Cp_Cb_Csp(TL, sInds, fZ, fEZ, dMag, WA, mode, TK=TK)
     57 
     58         # get SNR threshold

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/Prototypes/OpticalSystem.py in Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, returnExtra, TK)
    743 
    744         # get star magnitude, F0
--> 745         F_0, mag = TL.starF0(sInds, mode)
    746 
    747         C_F0 = F_0*self.pupilArea*deltaLam*inst['QE'](lam)*attenuation

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/Prototypes/TargetList.py in starF0(self, sInds, mode)
   1060             self.magdict[mode['hex']] = np.full(self.nStars, np.nan)
   1061             for j in sInds:
-> 1062                 self.F0dict[mode['hex']][j], self.magdict[mode['hex']][j] = self.F0(mode['BW'], mode['lam'], j, spec=self.Spec[j])
   1063 
   1064         return(self.F0dict[mode['hex']][sInds], self.magdict[mode['hex']][sInds])

/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/Prototypes/TargetList.py in F0(self, BW, lam, sInd, spec, teff)
    513             # Open corresponding spectrum
    514             with fits.open(os.path.join(self.specdatapath,
--> 515                            self.specindex[specmatch])) as hdulist:
    516                 sdat = hdulist[1].data
    517 

/usr/local/anaconda3/envs/cornell/lib/python3.7/site-packages/astropy/io/fits/hdu/hdulist.py in fitsopen(name, mode, memmap, save_backup, cache, lazy_load_hdus, **kwargs)
    156 
    157     return HDUList.fromfile(name, mode, memmap, save_backup, cache,
--> 158                             lazy_load_hdus, **kwargs)
    159 
    160 

/usr/local/anaconda3/envs/cornell/lib/python3.7/site-packages/astropy/io/fits/hdu/hdulist.py in fromfile(cls, fileobj, mode, memmap, save_backup, cache, lazy_load_hdus, **kwargs)
    395         return cls._readfrom(fileobj=fileobj, mode=mode, memmap=memmap,
    396                              save_backup=save_backup, cache=cache,
--> 397                              lazy_load_hdus=lazy_load_hdus, **kwargs)
    398 
    399     @classmethod

/usr/local/anaconda3/envs/cornell/lib/python3.7/site-packages/astropy/io/fits/hdu/hdulist.py in _readfrom(cls, fileobj, data, mode, memmap, save_backup, cache, lazy_load_hdus, **kwargs)
   1044             if not isinstance(fileobj, _File):
   1045                 # instantiate a FITS file object (ffo)
-> 1046                 fileobj = _File(fileobj, mode=mode, memmap=memmap, cache=cache)
   1047             # The Astropy mode is determined by the _File initializer if the
   1048             # supplied mode was None

/usr/local/anaconda3/envs/cornell/lib/python3.7/site-packages/astropy/utils/decorators.py in wrapper(*args, **kwargs)
    519                             kwargs[new_name[i]] = value
    520 
--> 521             return function(*args, **kwargs)
    522 
    523         return wrapper

/usr/local/anaconda3/envs/cornell/lib/python3.7/site-packages/astropy/io/fits/file.py in __init__(self, fileobj, mode, memmap, overwrite, cache)
    191             self._open_fileobj(fileobj, mode, overwrite)
    192         elif isinstance(fileobj, str):
--> 193             self._open_filename(fileobj, mode, overwrite)
    194         else:
    195             self._open_filelike(fileobj, mode, overwrite)

/usr/local/anaconda3/envs/cornell/lib/python3.7/site-packages/astropy/io/fits/file.py in _open_filename(self, filename, mode, overwrite)
    572 
    573         if not self._try_read_compressed(self.name, magic, mode, ext=ext):
--> 574             self._file = fileobj_open(self.name, IO_FITS_MODES[mode])
    575             self.close_on_error = True
    576 

/usr/local/anaconda3/envs/cornell/lib/python3.7/site-packages/astropy/io/fits/util.py in fileobj_open(filename, mode)
    394     """
    395 
--> 396     return open(filename, mode, buffering=0)
    397 
    398 

FileNotFoundError: [Errno 2] No such file or directory: '/proj/exep/rhonda/exosimsRhonda/EXOSIMS/EXOSIMS/TargetList/dat_uvk/bpgs_33.fits'


**To Reproduce**
this occured on instantiaion of the sim object in ipython:
scriptfile = '/proj/exep/rhonda/Sandbox/HabEx/Scripts/ATSA_LJSoC_DulzE_TV3_base_IR1_a.12b.5c0d0.3e0f.05_2
   ...: 0210423.json'                                                                                            

In [5]: sim = EXOSIMS.MissionSim.MissionSim(scriptfile,nopar=True, seed=777)  


**JSON script:**
The relevant part of the script is:
    {
      "name": "Occulter_testcase",
      "occulter": true,

      "lam": 1560,
      "IWA": 0.149,
      "OWA": 16.589,
      "ohTime": 0.33,
      "BW": 1.08,
      "optics": 1,
      "core_platescale": 0.1667,
      "occ_trans": "/proj/exep/rhonda/HabEx/Starshade/TV3_occ_trans_asec_650_49906.fits",
      "occulterDiameter": 72,
      "NocculterDistances" :1,
      "occulterDistances": [
        {
        "occulterDistance": 49906,
        "occulterRedEdge": 2400,
        "occulterBlueEdge": 720
        }
      ],
      "core_thruput": "/proj/exep/rhonda/HabEx/Starshade/TV3_core_thruput_asec_650_49906.fits",
      "core_mean_intensity": "/proj/exep/rhonda/HabEx/Starshade/TV3_core_mean_intensity_asec_650_49906.fits",
      "koAngles_Small": [1,180],
      "koAngles_Sun": [40,83],
      "koAngles_Moon": [1,180],
      "koAngles_Earth": [1,180]
   }
 ],
      "observingModes": [
    {
      "instName": "imagingEMCCD",
      "systName": "Occulter_testcase",
      "detection": 1,
      "lam": 1560,
      "SNR": 7
    },
    {
      "instName": "spectroEMCCD",
      "systName": "Occulter_testcase",
      "SNR": 10
    }
  ],
  "modules": {
    "PlanetPopulation": "AlbedoByRadiusDulzPlavchan",
    "StarCatalog": "EXOCAT1",
    "OpticalSystem": "Nemati",
    "ZodiacalLight": "Mennesson",
    "BackgroundSources": "GalaxiesFaintStars",
    "PlanetPhysicalModel": "Forecaster",
    "Observatory": "WFIRSTObservatoryL2",
    "TimeKeeping": " ",
    "PostProcessing": " ",
    "Completeness": "BrownCompleteness",
    "TargetList": "",
    "SimulatedUniverse": "DulzPlavchanUniverseEarthsOnly",
    "SurveySimulation": "linearJScheduler_orbitChar",
    "SurveyEnsemble": "EXOSIMS_local.IPClusterEnsembleJPL2"
  },
   "completeness_specs":{
     "modules":{
       "PlanetPopulation": "EarthTwinHabZone1SDET", 
       "PlanetPhysicalModel": " "
   }
  }


**EXOSIMS version:**
Balmer2020

**Additional context**
I remember many libraries being discussed.  I will rumage through old emails to see if I can find the libraries with bpgs files and download them.

Phototometry branch: MeanStars: spectral class parsing
Not a critical issue, I patched it outside the distribution, reported so it's not lost.

**Describe the bug**
spectral class parsing throws error

**To Reproduce**
Steps to reproduce the behavior:
matchSpecType where spectral type is "G2/G3"

NB: seems OK with "G3/G5 V"

**Expected behavior**
No throw

**JSON script:**
Pasted. I'm using the standard Votable that comes with the distribution.

**EXOSIMS version:**
 - Release or github commit/branch

**Additional context**
Error trace:

  File "/proj/exep/rhonda/Sandbox/EXOSIMS-versions/Photometry2022/EXOSIMS/EXOSIMS/Prototypes/SimulatedUniverse.py", line 205, in __init__
    self.TargetList = get_module(specs["modules"]["TargetList"], "TargetList")(
  File "/proj/exep/rhonda/Sandbox/EXOSIMS-versions/Photometry2022/EXOSIMS/EXOSIMS/TargetList/KnownRVPlanetsTargetList.py", line 64, in __init__
    TargetList.__init__(self, **specs)
  File "/proj/exep/rhonda/Sandbox/EXOSIMS-versions/Photometry2022/EXOSIMS/EXOSIMS/Prototypes/TargetList.py", line 404, in __init__
    self.fillPhotometryVals()
  File "/proj/exep/rhonda/Sandbox/EXOSIMS-versions/Photometry2022/EXOSIMS/EXOSIMS/Prototypes/TargetList.py", line 952, in fillPhotometryVals
    tmp = self.ms.matchSpecType(s)
  File "/usr/local/anaconda3/envs/cornell-v2/lib/python3.10/site-packages/MeanStars/MeanStars.py", line 659, in matchSpecType
    specSubClass = np.array(tmp2).astype(float).mean()
ValueError: could not convert string to float: ''

------------------
{
  "ORIGIN": "Developed by Rhonda Morgan, 2016/08/24.  Updated as EXOSIMS changed, 2016/09-2016/10.  More modifications were made for integration time testing (turmon).",
  "missionLife": 0.10,
  "missionPortion": 1.0,
  "duration": 100,
  "keepStarCatalog": false,
  "rvplanetfilepath_comment": "This element of the specs dictionary is set in the calling python driver",
  "minComp": 0.1,
  "pupilDiam": 4,
  "obscurFac": 0,
  "focal": 120,
  "pitch": 5e-6,
  "dMagLim": 26.0,
  "telescopeKeepout": 45,
  "attenuation": 0.6333,
  "intCutoff": 100.0,
  "magZ": 23.34,
  "magEZ": 23.34,
  "varEZ_comment": "photometry testing: suppress randomization of exo-zodi by setting var = 0.",
  "varEZ": 0.0,
  "FAP": 3e-5,
  "MDP": 1e-3, 
  "ppFact": 0.1,
  "prange": [0.2, 0.2],
  "erange": [0, 0.01],
  "wrange_note": "de-randomize the argument to perigee (gen_w in PlanetPopulation)",
  "wrange": [0.0, 0.0],
  "Orange_note": "de-randomize the long. of the ascending node (gen_O in PlanetPopulation)",
  "Orange": [0.0, 0.0],
  "scaleOrbits": false,
  "scienceInstruments": [
    {
      "name": "imager-EMCCD",
      "QE": 0.91,
      "idark": 5e-4,
      "CIC": 3e-3,
      "sread": 0.2,
      "ENF": 1,
      "Gem": 450,
      "texp": 1000
    },
    {
      "name": "spectro-EMCCD",
      "QE": 0.91,
      "idark": 5e-4,
      "CIC": 3e-3,
      "sread": 0.2,
      "ENF": 1,
      "Gem": 450,
      "texp": 1000,
      "Rs": 70
    }
  ],
  "starlightSuppressionSystems": [
    {
      "name": "HLC-550",
      "IWA": 0.0709,
      "OWA": 0.7094,
      "lam": 550,
      "BW": 0.20,
      "occ_trans": 0.3471,
      "core_thruput": 0.02, 
      "core_mean_intensity": 4e-14
    },
    {
      "name": "HLC-750",
      "IWA": 0.0709,
      "OWA": 0.7094,
      "ohTime": 0.02,
      "lam": 750,
      "occ_trans": 0.3471,
      "core_thruput": 0.02, 
      "core_mean_intensity": 4e-14
    }
  ],
  "observingModes": [
    {
        "description": "index = 0, used for detection at 750nm",
        "instName": "imager-EMCCD",
        "systName": "HLC-750",
        "SNR": 5,
        "detectionMode": true
    },
    {
        "description": "index = 1, used for characterization at 750nm",
        "instName": "spectro-EMCCD",
        "systName": "HLC-750",
        "SNR": 10,
        "detectionMode": false
    }
  ],
  "modules_comment": "Original SimulatedUniverse module was KnownRVPlanetsUniverse, changed to Deterministic for repeatable testing.",
  "modules": {
    "PlanetPopulation": "KnownRVPlanets",
    "StarCatalog": " ",
    "OpticalSystem": "Nemati",
    "ZodiacalLight": " ",
    "BackgroundSources": "GalaxiesFaintStars",
    "PlanetPhysicalModel": " ",
    "Observatory": "WFIRSTObservatoryL2",
    "TimeKeeping": " ",
    "PostProcessing": " ",
    "Completeness": " ",
    "TargetList": "KnownRVPlanetsTargetList",
    "SimulatedUniverse": "KnownRVPlanetsUniverse",
    "SurveySimulation": " ",
    "SurveyEnsemble": " "
  }
}

saturation dMag fix for occulters w/o speckle noise
## Describe your changes
In the TargetList prototype the calc_saturation_dMag function always assumed that the denominator of the integration time used the model from Nemati. But the calc_intTime function treats occulters differently and assumes no speckle noise. That same condition was added to the calc_saturation_dMag function so that the values returned are infinite instead of assuming speckle noise.

In GarrettCompleteness there was an error in the handling of infinite smax values which occur for occulters. Added a check that changes infinite smax values to the max float value.

## Type of change

- Bug fix (non-breaking change which fixes an issue)

## Reference any relevant issues (don't forget the #)
N/A

## Checklist before requesting a review
- [x] I have verified that all unit tests pass in a clean virtual environment and added new unit tests, as needed
- [x] I have run ``e2eTests`` and added new test scripts, as needed
- [x] I have verified that all docstrings are properly formatted and added new documentation, as needed



Discussion / hints:
more importantly than the missing files, I was under the impression that this version should fail gracefully by using the blackbody approximation if a spectra files is not available.
I downloaded the bpgs*.fits files and placed them in /dat_uvk/
The code runs with these files in place.
The bpgs fits files will be available once my fork is merged.
This should also cover #275 and #280 
more importantly than the missing files, I was under the impression that this version should fail gracefully by using the blackbody approximation if a spectra files is not available.
I downloaded the bpgs*.fits files and placed them in /dat_uvk/
The code runs with these files in place.
The bpgs fits files will be available once my fork is merged.



Fix patch:
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 000000000..5b4cd1992
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,29 @@
+# .readthedocs.yaml
+# Read the Docs configuration file
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+
+# Required
+version: 2
+
+# Set the version of Python and other tools you might need
+build:
+  os: ubuntu-20.04
+  tools:
+    python: "3.8"
+    nodejs: "16"
+  jobs:
+    post_install:
+      - npm install -g @mermaid-js/mermaid-cli
+
+# Build documentation in the docs/ directory with Sphinx
+sphinx:
+   configuration: documentation/conf.py
+
+# If using Sphinx, optionally build your docs in additional formats such as PDF
+formats:
+  - epub
+  - pdf
+
+python:
+   install:
+   - requirements: documentation/doc_requirements.txt
diff --git a/EXOSIMS/MissionSim.py b/EXOSIMS/MissionSim.py
index 23bc8d573..2ea31fd91 100644
--- a/EXOSIMS/MissionSim.py
+++ b/EXOSIMS/MissionSim.py
@@ -2,7 +2,7 @@
 from EXOSIMS.util.get_module import get_module
 from EXOSIMS.util.waypoint import waypoint
 from EXOSIMS.util.CheckScript import CheckScript
-from EXOSIMS.util.keyword_fun import get_all_args
+from EXOSIMS.util.keyword_fun import get_all_mod_kws, check_opticalsystem_kws
 import logging
 import json
 import os.path
@@ -101,7 +101,7 @@ def __init__(
         logfile=None,
         loglevel="INFO",
         checkInputs=True,
-        **specs
+        **specs,
     ):
         """Initializes all modules from a given script file or specs dictionary."""
 
@@ -205,32 +205,20 @@ def check_ioscripts(self) -> None:
         report and discrepancies.
 
         """
-        # collect keywords
-        allkws = []
-        allkwmods = []
-        for mod in self.modules.values():
-            tmp = get_all_args(mod.__class__)
-            allkws += tmp
-            allkwmods += [mod.__class__.__name__] * len(tmp)
-        # don't forget mission sim and starcatalog
-        tmp = get_all_args(self.__class__)
-        allkws += tmp
-        allkwmods += ["MissionSim"] * len(tmp)
 
+        # get a list of all modules in use
+        mods = {}
+        for modname in self.modules:
+            mods[modname] = self.modules[modname].__class__
+        mods["MissionSim"] = self.__class__
         if self.TargetList.keepStarCatalog:
-            tmp = get_all_args(self.TargetList.StarCatalog.__class__)
-            allkws += tmp
-            allkwmods += [self.TargetList.StarCatalog.__class__.__name__] * len(tmp)
+            mods["StarCatalog"] = self.TargetList.StarCatalog.__class__
         else:
-            tmp = get_all_args(self.TargetList.StarCatalog)
-            allkws += tmp
-            allkwmods += [self.TargetList.StarCatalog.__name__] * len(tmp)
-
-        # pop 'self' and 'scriptfile' and get unique args
-        ukws = np.array(allkws)
-        ukws = ukws[ukws != "self"]
-        ukws = ukws[ukws != "scriptfile"]
-        ukws, ukwcounts = np.unique(ukws, return_counts=True)
+            mods["StarCatalog"] = self.TargetList.StarCatalog
+
+        # collect keywords
+        allkws, allkwmods, ukws, ukwcounts = get_all_mod_kws(mods)
+
         self.vprint(
             (
                 "\nThe following keywords are used in multiple inits (this is ok):"
@@ -242,6 +230,8 @@ def check_ioscripts(self) -> None:
         unused = list(set(self.specs0.keys()) - set(ukws))
         if "modules" in unused:
             unused.remove("modules")
+        if "seed" in unused:
+            unused.remove("seed")
         if len(unused) > 0:
             warnstr = (
                 "\nThe following input keywords were not used in any "
@@ -254,6 +244,11 @@ def check_ioscripts(self) -> None:
             )
         )
 
+        # check the optical system
+        out = check_opticalsystem_kws(self.specs0, self.OpticalSystem)
+        if out != "":
+            warnings.warn(f"\n{out}")
+
         # and finally, let's look at the outspec
         outspec = self.genOutSpec(modnames=True)
         # these are extraneous things allowed to be in outspec:
diff --git a/EXOSIMS/OpticalSystem/KasdinBraems.py b/EXOSIMS/OpticalSystem/KasdinBraems.py
index 83e0f2881..d3a975c26 100644
--- a/EXOSIMS/OpticalSystem/KasdinBraems.py
+++ b/EXOSIMS/OpticalSystem/KasdinBraems.py
@@ -3,6 +3,8 @@
 import astropy.units as u
 import numpy as np
 import scipy.stats as st
+import os
+import astropy.io.fits as fits
 
 
 class KasdinBraems(OpticalSystem):
@@ -13,15 +15,44 @@ class KasdinBraems(OpticalSystem):
     the model from Kasdin & Braems 2006.
 
     Args:
-        specs:
+        PSF (~numpy.ndarray(float)):
+            Default point spread function suppression system. Only used when not
+            set in starlight suppression system definition. Defaults to np.ones((3,3))
+        **specs:
             user specified values
 
     """
 
-    def __init__(self, **specs):
+    def __init__(self, PSF=np.ones((3, 3)), **specs):
+
+        self.default_vals_extra = {"PSF": PSF}
 
         OpticalSystem.__init__(self, **specs)
 
+        # add local defaults to outspec
+        for k in self.default_vals_extra:
+            self._outspec[k] = self.default_vals_extra[k]
+
+    def populate_starlightSuppressionSystems_extra(self):
+
+        self.allowed_starlightSuppressionSystem_kws.append("PSF")
+
+        for nsyst, syst in enumerate(self.starlightSuppressionSystems):
+            # get PSF
+            syst["PSF"] = syst.get("PSF", self.default_vals_extra["PSF"])
+            if isinstance(syst["PSF"], str):
+                pth = os.path.normpath(os.path.expandvars(syst["PSF"]))
+                assert os.path.isfile(pth), "%s is not a valid file." % pth
+                with fits.open(pth) as f:
+                    hdr = f[0].header  # noqa: F841
+                    dat = f[0].data
+                assert len(dat.shape) == 2, "Wrong PSF data shape."
+                assert np.any(dat), "PSF must be != 0"
+                syst["PSF"] = lambda l, s, P=dat: P
+            else:
+                assert np.any(syst["PSF"]), "PSF must be != 0"
+                syst["PSF"] = lambda l, s, P=np.array(syst["PSF"]).astype(float): P
+
     def calc_intTime(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None):
         """Finds integration times of target systems for a specific observing
         mode (imaging or characterization), based on Kasdin and Braems 2006.
diff --git a/EXOSIMS/OpticalSystem/Nemati.py b/EXOSIMS/OpticalSystem/Nemati.py
index bb5c2f29c..a5d74c0f4 100644
--- a/EXOSIMS/OpticalSystem/Nemati.py
+++ b/EXOSIMS/OpticalSystem/Nemati.py
@@ -4,25 +4,211 @@
 import numpy as np
 from scipy.optimize import minimize_scalar
 from tqdm import tqdm
+from scipy.optimize import root_scalar
 
 
 class Nemati(OpticalSystem):
-    """Nemati Optical System class
+    r"""Nemati Optical System class
 
-    This class contains all variables and methods necessary to perform
-    Optical System Module calculations in exoplanet mission simulation using
-    the model from Nemati 2014.
+    Optical System Module based on [Nemati2014]_.
 
     Args:
-        specs:
-            user specified values
+        CIC (float):
+            Default clock-induced-charge (in electrons/pixel/read).  Only used
+            when not set in science instrument definition. Defaults to 1e-3
+        radDos (float):
+            Default radiation dose.   Only used when not set in mode definition.
+            Specific defintion depends on particular optical system. Defaults to 0.
+        PCeff (float):
+            Default photon counting efficiency.  Only used when not set
+            in science instrument definition. Defaults to 0.8
+        ENF (float):
+            Default excess noise factor.  Only used when not set
+            in science instrument definition. Defaults to 1.
+        ref_dMag (float):
+            Reference star :math:`\Delta\mathrm{mag}` for reference differential
+            imaging.  Defaults to 3.  Unused if ``ref_Time`` input is 0
+        ref_Time (float):
+            Faction of time used on reference star imaging. Must be between 0 and 1.
+            Defaults to 0
+        **specs:
+            :ref:`sec:inputspec`
+
+    Attributes:
+        default_vals_extra (dict):
+            Dictionary of input values to be filled in as defaults in the instrument,
+            starlight supporession system and observing modes. These values are specific
+            to this module.
+        ref_dMag (float):
+            Reference star :math:`\Delta\mathrm{mag}` for reference differential
+            imaging. Unused if ``ref_Time`` input is 0
+        ref_Time (float):
+            Faction of time used on reference star imaging.
 
     """
 
-    def __init__(self, **specs):
+    def __init__(
+        self, CIC=1e-3, radDos=0, PCeff=0.8, ENF=1, ref_dMag=3, ref_Time=0, **specs
+    ):
+
+        self.ref_dMag = float(ref_dMag)  # reference star dMag for RDI
+        self.ref_Time = float(ref_Time)  # fraction of time spent on ref star for RDI
+
+        # package inputs for use in popoulate*_extra
+        self.default_vals_extra = {
+            "CIC": CIC,
+            "radDos": radDos,
+            "PCeff": PCeff,
+            "ENF": ENF,
+        }
 
+        # call upstream init
         OpticalSystem.__init__(self, **specs)
 
+        # add local defaults to outspec
+        for k in self.default_vals_extra:
+            self._outspec[k] = self.default_vals_extra[k]
+
+    def populate_scienceInstruments_extra(self):
+        """Add Nemati-specific keywords to scienceInstruments"""
+        newatts = [
+            "CIC",  # clock-induced-charge
+            "ENF",  # excess noise factor
+            "PCeff",  # photon counting efficiency
+        ]
+        self.allowed_scienceInstrument_kws += newatts
+
+        for ninst, inst in enumerate(self.scienceInstruments):
+            for att in newatts:
+                inst[att] = float(inst.get(att, self.default_vals_extra[att]))
+                self._outspec["scienceInstruments"][ninst][att] = inst[att]
+
+    def populate_observingModes_extra(self):
+        """Add Nemati-specific observing mode keywords"""
+
+        self.allowed_observingMode_kws.append("radDos")
+
+        for nmode, mode in enumerate(self.observingModes):
+            # radiation dosage, goes from 0 (beginning of mission) to 1 (end of mission)
+            mode["radDos"] = float(
+                mode.get("radDos", self.default_vals_extra["radDos"])
+            )
+            self._outspec["observingModes"][nmode]["radDos"] = mode["radDos"]
+
+    def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, returnExtra=False, TK=None):
+        """Calculates electron count rates for planet signal, background noise,
+        and speckle residuals.
+
+        Args:
+            TL (:ref:`TargetList`):
+                TargetList class object
+            sInds (~numpy.ndarray(int)):
+                Integer indices of the stars of interest
+            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
+                Surface brightness of local zodiacal light in units of 1/arcsec2
+            fEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
+                Surface brightness of exo-zodiacal light in units of 1/arcsec2
+            dMag (~numpy.ndarray(float)):
+                Differences in magnitude between planets and their host star
+            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
+                Working angles of the planets of interest in units of arcsec
+            mode (dict):
+                Selected observing mode
+            returnExtra (bool):
+                Optional flag, default False, set True to return additional rates for
+                validation
+            TK (:ref:`TimeKeeping`, optional):
+                Optional TimeKeeping object (default None), used to model detector
+                degradation effects where applicable.
+
+
+        Returns:
+            tuple:
+                C_p (~astropy.units.Quantity(~numpy.ndarray(float))):
+                    Planet signal electron count rate in units of 1/s
+                C_b (~astropy.units.Quantity(~numpy.ndarray(float))):
+                    Background noise electron count rate in units of 1/s
+                C_sp (~astropy.units.Quantity(~numpy.ndarray(float))):
+                    Residual speckle spatial structure (systematic error)
+                    in units of 1/s
+
+        """
+        # grab all count rates
+        C_star, C_p0, C_sr, C_z, C_ez, C_dc, C_bl, Npix = self.Cp_Cb_Csp_helper(
+            TL, sInds, fZ, fEZ, dMag, WA, mode
+        )
+
+        inst = mode["inst"]
+
+        # exposure time
+        if self.texp_flag:
+            texp = 1 / C_p0 / 10  # Use 1/C_p0 as frame time for photon counting
+        else:
+            texp = inst["texp"]
+        # readout noise
+        C_rn = Npix * inst["sread"] / texp
+
+        # clock-induced-charge
+        C_cc = Npix * inst["CIC"] / texp
+
+        # C_p = PLANET SIGNAL RATE
+        # photon counting efficiency
+        PCeff = inst["PCeff"]
+        # radiation dosage
+        radDos = mode["radDos"]
+        # photon-converted 1 frame (minimum 1 photon)
+        # there may be zeros in the denominator. Suppress the resulting warning:
+        with np.errstate(divide="ignore", invalid="ignore"):
+            phConv = np.clip(
+                ((C_p0 + C_sr + C_z + C_ez) / Npix * texp).decompose().value, 1, None
+            )
+        # net charge transfer efficiency
+        NCTE = 1.0 + (radDos / 4.0) * 0.51296 * (np.log10(phConv) + 0.0147233)
+        # planet signal rate
+        C_p = C_p0 * PCeff * NCTE
+        # possibility of Npix=0 may lead C_p to be nan.  Change these to zero instead.
+        C_p[np.isnan(C_p)] = 0 / u.s
+
+        # C_b = NOISE VARIANCE RATE
+        # corrections for Ref star Differential Imaging e.g. dMag=3 and 20% time on ref
+        # k_SZ for speckle and zodi light, and k_det for detector
+        k_SZ = (
+            1.0 + 1.0 / (10 ** (0.4 * self.ref_dMag) * self.ref_Time)
+            if self.ref_Time > 0
+            else 1.0
+        )
+        k_det = 1.0 + self.ref_Time
+        # calculate Cb
+        ENF2 = inst["ENF"] ** 2
+        C_b = k_SZ * ENF2 * (C_sr + C_z + C_ez + C_bl) + k_det * (
+            ENF2 * (C_dc + C_cc) + C_rn
+        )
+        # for characterization, Cb must include the planet
+        if not (mode["detectionMode"]):
+            C_b = C_b + ENF2 * C_p0
+            C_sp = C_sr * TL.PostProcessing.ppFact_char(WA) * self.stabilityFact
+        else:
+            # C_sp = spatial structure to the speckle including post-processing
+            #        contrast factor and stability factor
+            C_sp = C_sr * TL.PostProcessing.ppFact(WA) * self.stabilityFact
+
+        if returnExtra:
+            # organize components into an optional fourth result
+            C_extra = dict(
+                C_sr=C_sr.to("1/s"),
+                C_z=C_z.to("1/s"),
+                C_ez=C_ez.to("1/s"),
+                C_dc=C_dc.to("1/s"),
+                C_cc=C_cc.to("1/s"),
+                C_rn=C_rn.to("1/s"),
+                C_star=C_star.to("1/s"),
+                C_p0=C_p0.to("1/s"),
+                C_bl=C_bl.to("1/s"),
+            )
+            return C_p.to("1/s"), C_b.to("1/s"), C_sp.to("1/s"), C_extra
+        else:
+            return C_p.to("1/s"), C_b.to("1/s"), C_sp.to("1/s")
+
     def calc_intTime(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None):
         """Finds integration times of target systems for a specific observing
         mode (imaging or characterization), based on Nemati 2014 (SPIE).
@@ -125,26 +311,23 @@ def calc_dMag_per_intTime(
         inst = mode["inst"]
         syst = mode["syst"]
 
-        # get mode wavelength
+        # get mode wavelength and attenuation
         lam = mode["lam"]
-        # get mode fractional bandwidth
-        BW = mode["BW"]  # noqa: F841
+        attenuation = inst["optics"] * syst["optics"]
         # get mode bandwidth (including any IFS spectral resolving power)
         deltaLam = (
             lam / inst["Rs"] if "spec" in inst["name"].lower() else mode["deltaLam"]
         )
 
-        # get star magnitude
-        mV = TL.starMag(sInds, lam)
+        # Star fluxes (ph/m^2/s)
+        flux_star = TL.starFlux(sInds, mode)
+        losses = (
+            self.pupilArea * inst["QE"](lam) * attenuation * deltaLam / mode["deltaLam"]
+        )
 
         # get signal to noise ratio
         SNR = mode["SNR"]
 
-        # spectral flux density = F0 * A * Dlam * QE * T (attenuation due to optics)
-        attenuation = inst["optics"] * syst["optics"]
-        F_0 = TL.starF0(sInds, mode)
-        C_F0 = F_0 * self.pupilArea * deltaLam * inst["QE"](lam) * attenuation
-
         # get core_thruput
         core_thruput = syst["core_thruput"](lam, WA)
 
@@ -162,14 +345,13 @@ def calc_dMag_per_intTime(
             (
                 SNR
                 * np.sqrt(tmp + C_sp**2.0)
-                / (C_F0 * 10.0 ** (-0.4 * mV) * core_thruput * inst["PCeff"])
+                / (flux_star * losses * core_thruput * inst["PCeff"])
             )
             .decompose()
             .value
         )
-        dMag[
-            np.where(np.isnan(dMag))[0]
-        ] = 0.0  # this is an error catch. if intTimes = 0, the dMag becomes infinite
+        # this is an error catch. if intTimes = 0, the dMag becomes infinite
+        dMag[np.where(np.isnan(dMag))[0]] = 0.0
         rough_dMags = dMag
         # Because Cb is a function of dMag, the rough dMags may be off by
         # ~10^-2, but it is useful as a center point for root-finding brackets
@@ -271,3 +453,101 @@ def ddMag_dt(
         )
 
         return ddMagdt / u.s
+
+    def dMag_per_intTime_obj(self, dMag, *args):
+        """
+        Objective function for calc_dMag_per_intTime's minimize_scalar function
+        that uses calc_intTime from Nemati and then compares the value to the
+        true intTime value
+
+        Args:
+            dMag (~numpy.ndarray(float)):
+                dMag being tested
+            *args:
+                all the other arguments that calc_intTime needs
+
+        Returns:
+            ~numpy.ndarray(float):
+                Absolute difference between true and evaluated integration time in days.
+        """
+        TL, sInds, fZ, fEZ, WA, mode, TK, true_intTime = args
+        est_intTime = self.calc_intTime(TL, sInds, fZ, fEZ, dMag, WA, mode, TK)
+        abs_diff = np.abs(true_intTime.to("day").value - est_intTime.to("day").value)
+        return abs_diff
+
+    def calc_saturation_dMag(self, TL, sInds, fZ, fEZ, WA, mode, TK=None):
+        """
+        This calculates the delta magnitude for each target star that
+        corresponds to an infinite integration time.
+
+        Args:
+            TL (:ref:`TargetList`):
+                TargetList class object
+            sInds (numpy.ndarray(int)):
+                Integer indices of the stars of interest
+            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
+                Surface brightness of local zodiacal light in units of 1/arcsec2
+            fEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
+                Surface brightness of exo-zodiacal light in units of 1/arcsec2
+            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
+                Working angles of the planets of interest in units of arcsec
+            mode (dict):
+                Selected observing mode
+            TK (:ref:`TimeKeeping`, optional):
+                Optional TimeKeeping object (default None), used to model detector
+                degradation effects where applicable.
+
+        Returns:
+            ~numpy.ndarray(float):
+                Maximum achievable dMag for  each target star
+        """
+
+        # cast sInds to array
+        sInds = np.array(sInds, ndmin=1, copy=False)
+
+        # TODO: revisit this if updating occulter noise floor model
+        if mode["syst"].get("occulter"):
+            saturation_dMag = np.full(shape=len(sInds), fill_value=np.inf)
+        else:
+            saturation_dMag = np.zeros(len(sInds))
+            for i, sInd in enumerate(tqdm(sInds, desc="Calculating saturation_dMag")):
+                args = (
+                    TL,
+                    [sInd],
+                    [fZ[i].value] * fZ.unit,
+                    [fEZ[i].value] * fEZ.unit,
+                    [WA[i].value] * WA.unit,
+                    mode,
+                    TK,
+                )
+                singularity_res = root_scalar(
+                    self.int_time_denom_obj,
+                    args=args,
+                    method="brentq",
+                    bracket=[10, 40],
+                )
+                singularity_dMag = singularity_res.root
+                saturation_dMag[i] = singularity_dMag
+
+        return saturation_dMag
+
+    def int_time_denom_obj(self, dMag, *args):
+        """
+        Objective function for calc_dMag_per_intTime's calculation of the root
+        of the denominator of calc_inTime to determine the upper bound to use
+        for minimizing to find the correct dMag
+
+        Args:
+            dMag (~numpy.ndarray(float)):
+                dMag being tested
+            *args:
+                all the other arguments that calc_intTime needs
+
+        Returns:
+            ~astropy.units.Quantity(~numpy.ndarray(float)):
+                Denominator of integration time expression
+        """
+        TL, sInds, fZ, fEZ, WA, mode, TK = args
+        C_p, C_b, C_sp = self.Cp_Cb_Csp(TL, sInds, fZ, fEZ, dMag, WA, mode, TK=TK)
+        denom = C_p.decompose().value ** 2 - (mode["SNR"] * C_sp.decompose().value) ** 2
+        return denom
diff --git a/EXOSIMS/OpticalSystem/Nemati_2019.py b/EXOSIMS/OpticalSystem/Nemati_2019.py
index 47dd92cb7..3be58ad01 100755
--- a/EXOSIMS/OpticalSystem/Nemati_2019.py
+++ b/EXOSIMS/OpticalSystem/Nemati_2019.py
@@ -5,6 +5,7 @@
 from scipy import interpolate
 from scipy.optimize import minimize_scalar
 from scipy.optimize import root_scalar
+import warnings
 
 
 class Nemati_2019(Nemati):
@@ -15,16 +16,62 @@ class Nemati_2019(Nemati):
     the model from Nemati 2014.
 
     Args:
-        specs:
+        k_samp (float):
+            Default coronagraphic intrinsice sampling. Only used if not set in
+            instrument definition. Defaults to 0.25.
+            TODO: move to starlight suppresion system.
+        Nlensl (float):
+            Total lenslets covered by PSF core.  Only used when not set
+            in science instrument definition. Only applies for spectrometers.
+            Defaults to 5
+        lam_d (float):
+            Default instrument design wavelength (in nm).  Only used when not set
+            in science instrument definition. Defaults to 500
+        lam_c (float):
+            Default instrument critical wavelength (in nm).  Only used when not set
+            in science instrument definition. Defaults to 500
+        MUF_thruput (float):
+            Model uncertainty factor core throughput.  Only used when not set
+            in science instrument definition. Defaults to 0.91
+        **specs:
             :ref:`sec:inputspec`
 
+    Attributes:
+        default_vals_extra2 (dict):
+            Dictionary of input values to be filled in as defaults in the instrument,
+            starlight supporession system and observing modes. These values are specific
+            to this module.
 
     """
 
-    def __init__(self, **specs):
+    def __init__(
+        self,
+        k_samp=0.25,
+        Nlensl=5,
+        lam_d=500,
+        lam_c=500,
+        MUF_thruput=0.91,
+        ContrastScenario="CGDesignPerf",
+        **specs,
+    ):
 
+        # package up input defaults for later use:
+        self.default_vals_extra2 = {
+            "k_samp": k_samp,
+            "Nlensl": Nlensl,
+            "lam_d": lam_d,
+            "lam_c": lam_c,
+            "MUF_thruput": MUF_thruput,
+            "ContrastScenario": ContrastScenario,
+        }
+
+        # call upstream init
         Nemati.__init__(self, **specs)
 
+        # add local defaults to outspec
+        for k in self.default_vals_extra2:
+            self._outspec[k] = self.default_vals_extra2[k]
+
         # If amici-spec, load Disturb x Sens Tables
         # DELETE amici_mode = [self.observingModes[i] for i in
         #                       np.arange(len(self.observingModes))
@@ -203,6 +250,50 @@ def extractedCSVTable(fname):
 
         # print(saltyburrito)
 
+    def populate_scienceInstruments_extra(self):
+        """Add Nemati_2019-specific keywords to scienceInstruments"""
+
+        # first call the Nemati version to get its specific values in there
+        super().populate_scienceInstruments_extra()
+
+        newatts = [
+            "k_samp",  # coronagraph intrinsic sampling
+            "lam_d",  # design wavelength
+            "lam_c",  # critical wavelength
+            "MUF_thruput",  # core model uncertainty throughput
+        ]
+        self.allowed_scienceInstrument_kws += newatts
+
+        # and now do ours:
+        for ninst, inst in enumerate(self.scienceInstruments):
+            for att in newatts:
+                inst[att] = float(inst.get(att, self.default_vals_extra2[att]))
+                self._outspec["scienceInstruments"][ninst][att] = inst[att]
+
+            # parameters specific to spectrograph
+            if "spec" in inst["name"].lower():
+                # lenslets in core
+                inst["Nlensl"] = float(
+                    inst.get("Nlensl", self.default_vals_extra2["Nlensl"])
+                )
+            else:
+                inst["Nlensl"] = 5.0
+            self._outspec["scienceInstruments"][ninst]["Nlensl"] = inst["Nlensl"]
+
+    def populate_observingModes_extra(self):
+        """Add Nemati_2019-specific observing mode keywords"""
+
+        super().populate_observingModes_extra()
+        self.allowed_observingMode_kws.append("ContrastScenario")
+
+        for nmode, mode in enumerate(self.observingModes):
+            mode["ContrastScenario"] = mode.get(
+                "ContrastScenario", self.default_vals_extra2["ContrastScenario"]
+            )
+            self._outspec["observingModes"][nmode]["ContrastScenario"] = mode[
+                "ContrastScenario"
+            ]
+
     def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None, returnExtra=False):
         """Calculates electron count rates for planet signal, background noise,
         and speckle residuals.
@@ -273,7 +364,9 @@ def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None, returnExtra=Fal
 
         lam_D = lam.to(u.m) / (D_PM * u.mas.to(u.rad))  # Diffraction limit
 
-        F_0 = TL.starF0(sInds, mode) * BW * lam
+        # F_0 = TL.starF0(sInds, mode) * BW * lam #starF0 deprecated
+        # TODO: need to generalize this to other bands, same as new prototype.
+        F_0 = mode["F0"]
 
         # Setting in the json file that differentiates between MCBE, ICBE, REQ
         try:
@@ -620,11 +713,6 @@ def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None, returnExtra=Fal
                 )  # k_pp #CStability!E5 and CStability!H34
         elif mode["ContrastScenario"] == "2019_PDR_Update":
             # This is the new contrast scenario from the spreadsheet
-            # Draw the values for the coronagraph contrast from the csv files
-            positional_WA = (WA.to(u.mas) / lam_D).value
-            positional_OWA = (mode["OWA"].to("mas") / lam_D).value
-            positional_IWA = (mode["IWA"].to("mas") / lam_D).value
-
             # Draw the necessary values from the csv files
             core_stability_x, C_CG_y, C_extsta_y, C_intsta_y = self.get_csv_values(
                 syst["core_stability"],
@@ -634,6 +722,16 @@ def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None, returnExtra=Fal
                 CS_setting + "_IntContStab",
             )
 
+            # Draw the values for the coronagraph contrast from the csv files
+            if mode.get("mimic_spreadsheet") and type(WA.value) != np.ndarray:
+                positional_WA = core_stability_x[
+                    core_stability_x < (WA.to(u.mas) / lam_D).value
+                ][-1]
+            else:
+                positional_WA = (WA.to(u.mas) / lam_D).value
+            positional_OWA = (mode["OWA"].to("mas") / lam_D).value
+            positional_IWA = (mode["IWA"].to("mas") / lam_D).value
+
             # In the DRM scenarios we want the core stability table out to the
             # full working angle range, even though the cs table doesn't go
             # that far, so adjust the positional_WA to cap out at the final
@@ -686,46 +784,36 @@ def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None, returnExtra=Fal
             C_CG = syst["core_contrast"](lam, WA)  # coronagraph contrast
             dC_CG = C_CG / (5.0 * k_pp)  # SNR!E6
 
-        # THIS IS FOR TESTING THE DIFFERENCE BETWEEN INTERPOLATION AND
-        # THE SPREADSHEET'S FLOORING
-        # cgperf_WA = np.arange(5.9, 20.1, 0.3)*lam_D/10**3*u.arcsec
-        # # print(WA)
-        # WA = min(cgperf_WA, key = lambda x:abs(x-WA))
-        # print(f'WA: {WA}')
+        if mode.get("mimic_spreadsheet") and type(WA.value) != np.ndarray:
+            # Debug tool to match spreadsheet's flooring of csv files
+            cgperf_WA = (
+                np.genfromtxt(syst["CGPerf"], delimiter=",")[1:, 0]
+                * lam_D
+                / 10**3
+                * u.arcsec
+            )
+            WA = cgperf_WA[cgperf_WA < WA][-1]
 
         A_PSF = syst["core_area"](lam, WA)  # PSF area
-        # This filter will set the PSF area when core_area is not given or the
-        # lambda function is outside the given range
-        # Check to see if it's an array or a single value
-        if type(A_PSF) == np.float64:
-            if A_PSF == 0:
-                A_PSF = np.pi * (np.sqrt(2.0) / 2.0 * lam / self.pupilDiam) ** 2.0
-        else:
-            A_PSF[A_PSF == 0] = (
-                np.pi * (np.sqrt(2.0) / 2.0 * lam / self.pupilDiam) ** 2.0
-            )
         I_pk = syst["core_mean_intensity"](lam, WA)  # peak intensity
         tau_core = syst["core_thruput"](lam, WA) * inst["MUF_thruput"]  # core thruput
         tau_occ = syst["occ_trans"](lam, WA)  # Occular transmission
 
         R = inst["Rs"]  # resolution
 
-        # THIS IS FOR TESTING THE DIFFERENCE BETWEEN INTERPOLATION AND THE
-        # SPREADSHEET'S FLOORING
-        # QE_lambdas = np.arange(300, 1000, 10)*u.nm
-        # lam = min(QE_lambdas, key = lambda x:abs(x-lam))
-
-        eta_QE = inst["QE"](lam)[0].value  # quantum efficiency
-        refl_derate = inst["refl_derate"]  # noqa: F841
-
+        if mode.get("mimic_spreadsheet"):
+            # Debug tool to match spreadsheet's flooring of csv files
+            QE_lambdas = np.arange(300, 1000, 10) * u.nm
+            QElam = QE_lambdas[QE_lambdas < lam][-1]
+            eta_QE = inst["QE"](QElam)[0].value  # quantum efficiency
+        else:
+            eta_QE = inst["QE"](lam)[0].value  # quantum efficiency
         Nlensl = inst["Nlensl"]
         lenslSamp = inst["lenslSamp"]
-        lam_c = inst["lam_c"]
-        lam_d = inst["lam_d"]  # AK7
+        lam_c = inst["lam_c"] * lam.unit
+        lam_d = inst["lam_d"] * lam.unit  # AK7
         k_s = inst["k_samp"]  # AK19
-        CTE_derate = inst["CTE_derate"]  # noqa: F841
         ENF = inst["ENF"]  # excess noise factor
-        k_d = inst["dark_derate"]  # noqa: F841
         pixel_size = inst["pixelSize"]
         n_pix = inst["pixelNumber"] ** 2.0
 
@@ -738,7 +826,7 @@ def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None, returnExtra=Fal
 
         if "amici" in inst_name.lower():
             f_SR = 1.0 / (BW * R)
-            nld = (inst["Fnum"] * lam / pixel_size).decompose().value
+            nld = (inst["fnumber"] * lam / pixel_size).decompose().value
             ncore_x = 2.0 * 0.942 * nld
             ncore_y = 2.0 * 0.45 * nld
             Rcore = (
@@ -756,7 +844,7 @@ def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None, returnExtra=Fal
         else:  # Imaging Mode
             f_SR = 1.0
             m_pix = (
-                A_PSF
+                A_PSF.to(u.arcsec**2).value
                 * (np.pi / 180.0 / 3600.0) ** 2.0
                 * (lam / lam_d) ** 2
                 * (2 * D_PM / lam_c) ** 2
@@ -765,8 +853,14 @@ def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None, returnExtra=Fal
         # Get the file that has throughput information if a file is given
         try:
             thput_filename = inst["THPUT"]
+            if "REQ" in CS_setting:
+                thput_setting = "REQ"
+            else:
+                thput_setting = "CBE"
             OTA_TCA, CGI = self.get_csv_values(
-                thput_filename, "CBE_OTAplusTCA", "CBE_CGI"
+                thput_filename,
+                f"{thput_setting}_OTAplusTCA",
+                f"{thput_setting}_CGI",
             )
         except:  # noqa: E722
             OTA_TCA = 0.751
@@ -778,12 +872,16 @@ def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None, returnExtra=Fal
 
         for i in sInds:
             F_s = F_0 * 10.0 ** (-0.4 * m_s[i])
+        if mode.get("mimic_spreadsheet") and "test_star_flux" in inst.keys():
+            F_s = inst["test_star_flux"] * u.ph / (u.m**2 * u.s)
         F_P_s = 10.0 ** (-0.4 * dMag)
         F_p = F_P_s * F_s
 
         # ORIGINALm_pixCG = A_PSF*(D_PM/(lam_d*k_s))**2.*(np.pi/180./3600.)**2.
         m_pixCG = (
-            A_PSF * (np.pi / 180.0 / 3600.0) ** 2.0 / ((lam_d * k_s) / D_PM) ** 2.0
+            A_PSF.to(u.arcsec**2).value
+            * (np.pi / 180.0 / 3600.0) ** 2.0
+            / ((lam_d * k_s) / D_PM) ** 2.0
         )
 
         # Calculations of the local and extra zodical flux
@@ -807,9 +905,9 @@ def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None, returnExtra=Fal
             f_SR * F_s * C_CG * I_pk * m_pixCG * tau_sp * A_col * eta_QE
         )  # Dean replaces with tau_sp as in Bijan latex doc and  excel sheet
 
-        ezo_inc = f_SR * F_ezo * A_PSF * A_col * tau_unif  # U66
+        ezo_inc = f_SR * F_ezo * A_PSF.to(u.arcsec**2).value * A_col * tau_unif  # U66
 
-        lzo_inc = f_SR * F_lzo * A_PSF * A_col * tau_unif  # U67
+        lzo_inc = f_SR * F_lzo * A_PSF.to(u.arcsec**2).value * A_col * tau_unif  # U67
         r_zo_ia = (ezo_inc + lzo_inc) * eta_QE
 
         try:
@@ -819,6 +917,12 @@ def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None, returnExtra=Fal
                 det_filename, "Dark1", "Dark2", "DetEOL_mos"
             )
         except:  # noqa: E722
+            warnings.warn(
+                (
+                    f"Failed to load dark current values from {det_filename}."
+                    " Headers likely changed names"
+                )
+            )
             # Standard values
             dark1 = 1.5
             dark2 = 0.5
@@ -852,6 +956,12 @@ def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None, returnExtra=Fal
                 "PixelsAcross_pix",
             )
         except:  # noqa: E722
+            warnings.warn(
+                (
+                    f"Failed to load detector values from {det_filename}."
+                    " Headers likely changed names."
+                )
+            )
             k_RN = 100
             k_EM = 1900
             L_CR = 195
@@ -870,7 +980,7 @@ def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None, returnExtra=Fal
 
         signal_pix_frame = t_f * r_ph  # AC9
 
-        PC_eff_loss = 1 - np.exp(-PC_threshold * k_RN / k_EM)
+        PC_eff_loss = 1 - np.exp(-PC_threshold / k_EM)
         eta_PC = 1 - PC_eff_loss  # PC Threshold Efficiency SNR!AJK45
         eta_HP = 1.0 - t_MF / 20.0  # SNR!AJ39
         eta_CR = (
@@ -881,6 +991,12 @@ def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None, returnExtra=Fal
                 det_filename, "CTE_dqeFluxSlope", "CTE_dqeKnee", "CTE_dqeKneeFlux"
             )
         except:  # noqa: E722
+            warnings.warn(
+                (
+                    f"Failed to load dqe values from {det_filename}."
+                    " Headers likely changed names."
+                )
+            )
             dqeFluxSlope = 3.24
             dqeKnee = 0.858
             dqeKneeFlux = 0.089
@@ -905,7 +1021,10 @@ def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None, returnExtra=Fal
         # PC Coincidence Efficiency or Coincidence Loss SNR!AJ46
         eta_CL = (1 - np.exp(-tf_cts_pix_frame.value)) / tf_cts_pix_frame.value
 
-        deta_QE = eta_QE * eta_PC * eta_HP * eta_CL * eta_CR * eta_NCT
+        if "REQ" in CS_setting:
+            deta_QE = mode["inst"]["dQE"]
+        else:
+            deta_QE = eta_QE * eta_PC * eta_HP * eta_CL * eta_CR * eta_NCT
         r_ezo = ezo_inc * deta_QE
         r_lzo = lzo_inc * deta_QE
 
@@ -919,6 +1038,12 @@ def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None, returnExtra=Fal
                 det_filename, "CIC1", "CIC2", "CIC3", "CIC4"
             )
         except:  # noqa: E722
+            warnings.warn(
+                (
+                    f"Failed to load clock induced charge values from {det_filename}."
+                    " Headers likely changed names."
+                )
+            )
             det_CIC1 = 0.8
             det_CIC2 = 0.005
             det_CIC3 = 4500
@@ -1187,8 +1312,8 @@ def get_csv_values(self, csv_file, *headers):
         try:
             csv_vals = np.genfromtxt(filename, delimiter=",", skip_header=1)
         except (FileNotFoundError, UnicodeDecodeError, ValueError):
-            print("Error when reading csv file:")
-            print(filename)
+            warnings.warn("Error when reading csv file:")
+            warnings.warn(filename)
             csv_vals = np.genfromtxt(filename, delimiter=",", skip_header=1)
 
         # Get the number of rows, accounting for the fact that 1D numpy arrays
diff --git a/EXOSIMS/PlanetPopulation/KnownRVPlanets.py b/EXOSIMS/PlanetPopulation/KnownRVPlanets.py
index b7f456807..e74fab074 100644
--- a/EXOSIMS/PlanetPopulation/KnownRVPlanets.py
+++ b/EXOSIMS/PlanetPopulation/KnownRVPlanets.py
@@ -74,6 +74,7 @@ def __init__(
             raise IOError("RV Planet File %s Not Found." % rvplanetfilepath)
 
         # read votable
+        self.vprint("Loading target values from {}".format(rvplanetfilepath))
         with warnings.catch_warnings():
             # warnings for IPAC votables are out of control
             #   they are not moderated by pedantic=False
diff --git a/EXOSIMS/Prototypes/OpticalSystem.py b/EXOSIMS/Prototypes/OpticalSystem.py
index 187ac122f..82c8fba9c 100644
--- a/EXOSIMS/Prototypes/OpticalSystem.py
+++ b/EXOSIMS/Prototypes/OpticalSystem.py
@@ -3,6 +3,9 @@
 from EXOSIMS.util.get_dirs import get_cache_dir
 from EXOSIMS.util.utils import dictToSortedStr, genHexStr
 from EXOSIMS.util.keyword_fun import get_all_args
+from EXOSIMS.util.photometricModels import Box1D
+from synphot.models import Gaussian1D
+from synphot import SpectralElement, SourceSpectrum, Observation
 import os.path
 import numbers
 import numpy as np
@@ -10,6 +13,8 @@
 import astropy.io.fits as fits
 import scipy.interpolate
 import scipy.optimize
+import copy
+import warnings
 
 
 class OpticalSystem(object):
@@ -52,8 +57,8 @@ class OpticalSystem(object):
             coronagraph) optics. Only used when not set in science instrument
             definition. Defaults to 0.5
         FoV (float):
-            Default instrument field of view (in arcseconds). Only used when not set
-            in science instrument definition. Defaults to 10
+            Default instrument half-field of view (in arcseconds). Only used when not
+            set in science instrument definition. Defaults to 10
         pixelNumber (float):
             Default number of pixels across the detector. Only used when not set
             in science instrument definition. Defaults to 1000.
@@ -61,27 +66,19 @@ class OpticalSystem(object):
             Default pixel pitch (nominal distance between adjacent pixel centers,
             in meters). Only used when not set in science instrument definition.
             Defaults to 1e-5
+        pixelScale (float):
+            Default pixel scale (instantaneous field of view of each pixel,
+            in arcseconds). Only used when not set in science instrument definition.
+            Defaults to 0.02.
         sread (float):
             Default read noise (in electrons/pixel/read).  Only used when not set
             in science instrument definition. Defaults to 1e-6
         idark (float):
             Default dark current (in electrons/pixel/s).  Only used when not set
             in science instrument definition. Defaults to 1e-4
-        CIC (float):
-            Default clock-induced-charge (in electrons/pixel/read).  Only used
-            when not set in science instrument definition. Defaults to 1e-3
         texp (float):
             Default single exposure time (in s).  Only used when not set
             in science instrument definition. Defaults to 100
-        radDos (float):
-            Default radiation dose.   Only used when not set in mode definition.
-            Specific defintion depends on particular optical system. Defaults to 0.
-        PCeff (float):
-            Default photon counting efficiency.  Only used when not set
-            in science instrument definition. Defaults to 0.8
-        ENF (float):
-            Default excess noise factor.  Only used when not set
-            in science instrument definition. Defaults to 1.
         Rs (float):
             Default spectral resolving power.   Only used when not set
             in science instrument definition. Only applies to spetrometers.
@@ -110,9 +107,6 @@ class OpticalSystem(object):
         core_thruput (float):
             Default core throughput. Only used when not set in starlight suppression
             system definition.  Defaults to 0.1
-        core_mean_intensity (float):
-            Default core mean intensity. Only used when not set in starlight
-            suppression system definition. Defaults to 1e-12
         core_contrast (float):
             Default core contrast. Only used when not set in starlight suppression
             system definition. Defaults to 1e-10
@@ -122,10 +116,16 @@ class OpticalSystem(object):
             Defaults to None
         core_platescale (float, optional):
             Default core platescale.  Only used when not set in starlight suppression
-            system definition. Defaults to None
-        PSF (~numpy.ndarray(float)):
-            Default point spread function suppression system. Only used when not
-            set in starlight suppression system definition. Defaults to np.ones((3,3))
+            system definition. Defaults to None. Units determiend by
+            ``input_angle_units``.
+        input_angle_units (str, optional):
+            Default angle units of all starlightSuppressionSystems-related inputs
+            (as applicable). This includes all CSV input tables or FITS input tables
+            without a UNIT keyword in the header.
+            Only used when not set in starlight suppression system definition.
+            None, 'unitless' or 'LAMBDA/D' are all interepreted as :math:`\\lambda/D`
+            units. Otherwise must be a string that is parsable as an astropy angle unit.
+            Defaults to 'arcsec'.
         ohTime (float):
             Default overhead time (in days).  Only used when not set in starlight
             suppression system definition. Time is added to every observation (on
@@ -154,53 +154,16 @@ class OpticalSystem(object):
             (i.e., spectroscopic followup) it may not be necessary to integrate on the
             full field, in which case this quantity could be set to 1. Defaults to 1
         IWA (float):
-            Default :term:`IWA` (in arcsec).  Only used when not set in starlight
-            suppression system definition. Defaults to 0.1
+            Default :term:`IWA` (in input_angle_units).  Only used when not set in
+            starlight suppression system definition. Defaults to 0.1
         OWA (float):
-            Default :term:`OWA` (in arcsec). Only used when not set in starlight
-            suppression system definition. Defaults to numpy.Inf
-        ref_dMag (float):
-            Reference star :math:`\Delta\mathrm{mag}` for reference differential
-            imaging.  Defaults to 3.  Unused if ``ref_Time`` input is 0
-        ref_Time (float):
-            Faction of time used on reference star imaging. Must be between 0 and 1.
-            Defaults to 0
+            Default :term:`OWA` (in input_angle_units). Only used when not set in
+            starlight suppression system definition. Defaults to numpy.Inf
         stabilityFact (float):
             Stability factor. Defaults to 1
-        k_samp (float):
-            Default coronagraphic intrinsice sampling. Only used if not set in
-            instrument definition. Defaults to 0.25.
-            TODO: move to starlight suppresion system.
-        kRN (float):
-            Default camera read noise before EM gain or photon counting.  Only used if
-            not set in instrument definition.  Defaults to 75.0
-        CTE_derate (float):
-            Default charge transfer efficiency derate. Only used when not set
-            in science instrument definition. Defaults to 1.0
-        dark_derate (float):
-            Default dark current derate.  Only used when not set
-            in science instrument definition. Defaults to 1.0
-        refl_derate (float):
-            Default reflectivity derate.  Only used when not set
-            in science instrument definition. Defaults to 1.0,
-        Nlensl (float):
-            Total lenslets covered by PSF core.  Only used when not set
-            in science instrument definition. Only applies for spectrometers.
-            Defaults to 5
-        lam_d (float):
-            Default instrument design wavelength (in nm).  Only used when not set
-            in science instrument definition. Defaults to 500
-        lam_c (float):
-            Default instrument critical wavelength (in nm).  Only used when not set
-            in science instrument definition. Defaults to 500
-        MUF_thruput (float):
-            Model uncertainty factor core throughput.  Only used when not set
-            in science instrument definition. Defaults to 0.91
         cachedir (str, optional):
             Full path to cachedir.
             If None (default) use default (see :ref:`EXOSIMSCACHE`)
-        ContrastScenario (str):
-            Contrast scenario. Defaults to "CGDesignPerf"
         koAngles_Sun (list(float)):
             Default [Min, Max] keepout angles for Sun.  Only used when not set in
             starlight suppression system definition.  Defaults to [0,180]
@@ -214,21 +177,44 @@ class OpticalSystem(object):
             Default [Min, Max] keepout angles for all other bodies.  Only used when
             not set in starlight suppression system definition.
             Defaults to [0,180],
-        use_char_minintTime (bool):
-            DEPRECATED
         binaryleakfilepath (str, optional):
             If set, full path to binary leak definition file. Defaults to None
         texp_flag (bool):
             Toggle use of planet shot noise value for frame exposure time
             (overriides instrument texp value). Defaults to False.
+        bandpass_model (str):
+            Default model to use for mode bandpasses. Must be one of 'gaussian' or 'box'
+            (case insensitive). Only used if not set in mode definition. Defaults to
+            box.
+        bandpass_step (float):
+            Default step size (in nm) to use when generating Box-model bandpasses. Only
+            used if not set in mode definition. Defaults to 0.1.
+        use_core_thruput_for_ez (bool):
+            If True, compute exozodi contribution using core_thruput.
+            If False (default) use occ_trans
+        csv_angsep_colname (str):
+            Default column name to use for the angular separation column for CSV data.
+            Only used when not set in starlight suppression system definition.
+            Defaults to r_as (matching the default input_angle_units). These two inputs
+            should be updated together.
         **specs:
             :ref:`sec:inputspec`
 
     Attributes:
         _outspec (dict):
             :ref:`sec:outspec`
+        allowed_observingMode_kws (list):
+            List of allowed keywords in observingMode dictionaries
+        allowed_scienceInstrument_kws (list):
+            List of allowed keywords in scienceInstrument dictionaries
+        allowed_starlightSuppressionSystem_kws (list):
+            List of allowed keywords in starlightSuppressionSystem dictionaries
         cachedir (str):
             Path to the EXOSIMS cache directory (see :ref:`EXOSIMSCACHE`)
+        default_vals (dict):
+            All inputs not assigned to object attributes are considered to be default
+            values to be used for filling in information in the optical system
+            definition, and are copied into this dictionary for storage.
         haveOcculter (bool):
             One or more starlight suppresion systems are starshade-based
         intCutoff (astropy.units.quantity.Quantity):
@@ -263,11 +249,6 @@ class OpticalSystem(object):
             factor, and :math:`D` is the pupil diameter.
         pupilDiam (astropy.units.quantity.Quantity):
             Pupil major diameter. Length units.
-        ref_dMag (float):
-            Reference star :math:`\Delta\mathrm{mag}` for reference differential
-            imaging. Unused if ``ref_Time`` input is 0
-        ref_Time (float):
-            Faction of time used on reference star imaging.
         scienceInstruments (list):
             List of dicts defining all science instruments. Minimally must contain
             one science instrument. Each dictionary must minimally contain a ``name``
@@ -293,8 +274,9 @@ class OpticalSystem(object):
         texp_flag (bool):
             Toggle use of planet shot noise value for frame exposure time
             (overriides instrument texp value).
-        use_char_minintTime (bool):
-            DEPRECATED
+        use_core_thruput_for_ez (bool):
+            Toggle use of core_thruput (instead of occ_trans) in computing exozodi flux.
+
     """
 
     _modtype = "OpticalSystem"
@@ -311,13 +293,10 @@ def __init__(
         FoV=10,
         pixelNumber=1000,
         pixelSize=1e-5,
+        pixelScale=0.02,
         sread=1e-6,
         idark=1e-4,
-        CIC=1e-3,
         texp=100,
-        radDos=0,
-        PCeff=0.8,
-        ENF=1,
         Rs=50,
         lenslSamp=2,
         starlightSuppressionSystems=[{"name": "coronagraph"}],
@@ -325,39 +304,30 @@ def __init__(
         BW=0.2,
         occ_trans=0.2,
         core_thruput=0.1,
-        core_mean_intensity=1e-12,
         core_contrast=1e-10,
         contrast_floor=None,
         core_platescale=None,
-        PSF=np.ones((3, 3)),
+        core_platescale_units=None,
+        input_angle_units="arcsec",
         ohTime=1,
         observingModes=None,
         SNR=5,
         timeMultiplier=1.0,
-        IWA=None,
-        OWA=None,
-        ref_dMag=3,
-        ref_Time=0,
+        IWA=0.1,
+        OWA=np.Inf,
         stabilityFact=1,
-        k_samp=0.25,
-        kRN=75.0,
-        CTE_derate=1.0,
-        dark_derate=1.0,
-        refl_derate=1.0,
-        Nlensl=5,
-        lam_d=500,
-        lam_c=500,
-        MUF_thruput=0.91,
         cachedir=None,
-        ContrastScenario="CGDesignPerf",
         koAngles_Sun=[0, 180],
         koAngles_Earth=[0, 180],
         koAngles_Moon=[0, 180],
         koAngles_Small=[0, 180],
-        use_char_minintTime=False,
         binaryleakfilepath=None,
         texp_flag=False,
-        **specs
+        bandpass_model="box",
+        bandpass_step=0.1,
+        use_core_thruput_for_ez=False,
+        csv_angsep_colname="r_as",
+        **specs,
     ):
 
         # start the outspec
@@ -366,22 +336,35 @@ def __init__(
         # load the vprint function (same line in all prototype module constructors)
         self.vprint = vprint(specs.get("verbose", True))
 
-        # load all values with defaults
+        # set attributes from inputs
         self.obscurFac = float(obscurFac)  # obscuration factor (fraction of PM area)
         self.shapeFac = float(shapeFac)  # shape factor
         self.pupilDiam = float(pupilDiam) * u.m  # entrance pupil diameter
         self.intCutoff = float(intCutoff) * u.d  # integration time cutoff
-        self.ref_dMag = float(ref_dMag)  # reference star dMag for RDI
-        self.ref_Time = float(ref_Time)  # fraction of time spent on ref star for RDI
         self.stabilityFact = float(stabilityFact)  # stability factor for telescope
-        self.use_char_minintTime = bool(use_char_minintTime)
         self.texp_flag = bool(texp_flag)
+        self.use_core_thruput_for_ez = bool(use_core_thruput_for_ez)
 
         # get cache directory
         self.cachedir = get_cache_dir(cachedir)
         specs["cachedir"] = self.cachedir
 
-        # populate outspec with all OpticalSystem scalar attributes
+        # if binary leakage model provided, let's grab that as well
+        if binaryleakfilepath is not None:
+            assert os.path.exists(
+                binaryleakfilepath
+            ), "Binary leakage model data file not found at {}".format(
+                binaryleakfilepath
+            )
+
+            binaryleakdata = np.genfromtxt(binaryleakfilepath, delimiter=",")
+
+            self.binaryleakmodel = scipy.interpolate.interp1d(
+                binaryleakdata[:, 0], binaryleakdata[:, 1], bounds_error=False
+            )
+        self._outspec["binaryleakfilepath"] = binaryleakfilepath
+
+        # populate outspec with all attributes assigned so far
         for att in self.__dict__:
             if att not in [
                 "vprint",
@@ -390,59 +373,156 @@ def __init__(
                 dat = self.__dict__[att]
                 self._outspec[att] = dat.value if isinstance(dat, u.Quantity) else dat
 
-        # get all inputs that aren't attributes into the outspec as well
+        # consistency check IWA/OWA defaults
+        if OWA == 0:
+            OWA = np.Inf
+        assert IWA < OWA, "Input default IWA must be smaller than input default OWA."
+
+        # get all inputs that haven't been assiged to attributes will be treated as
+        # default values (and should also go into outspec)
         kws = get_all_args(self.__class__)
         ignore_kws = [
             "self",
             "scienceInstruments",
             "starlightSuppressionSystems",
             "observingModes",
+            "binaryleakfilepath",
         ]
-        kws = list(set(kws) - set(ignore_kws))
+        kws = list(
+            (set(kws) - set(ignore_kws) - set(self.__dict__.keys())).intersection(
+                set(locals().keys())
+            )
+        )
+        self.default_vals = {}
         for kw in kws:
+            self.default_vals[kw] = locals()[kw]
             if kw not in self._outspec:
                 self._outspec[kw] = locals()[kw]
 
         # pupil collecting area (obscured PM)
         self.pupilArea = (1 - self.obscurFac) * self.shapeFac * self.pupilDiam**2
 
-        # loop through all science Instruments (must have one defined)
+        # load Vega's spectrum for later calculations
+        self.vega_spectrum = SourceSpectrum.from_vega()
+
+        # populate science instruments (must have one defined)
         assert isinstance(scienceInstruments, list) and (
             len(scienceInstruments) > 0
         ), "No science instrument defined."
-        self.scienceInstruments = scienceInstruments
+        self.populate_scienceInstruments(scienceInstruments)
+
+        # populate starlight suppression systems (must have one defined)
+        assert isinstance(starlightSuppressionSystems, list) and (
+            len(starlightSuppressionSystems) > 0
+        ), "No starlight suppression systems defined."
+        self.populate_starlightSuppressionSystems(starlightSuppressionSystems)
+
+        # if no observing mode defined, create a default mode from the first instrument
+        # and first starlight suppression system. then populate all observing modes
+        if observingModes is None:
+            inst = self.scienceInstruments[0]
+            syst = self.starlightSuppressionSystems[0]
+            observingModes = [
+                {
+                    "detectionMode": True,
+                    "instName": inst["name"],
+                    "systName": syst["name"],
+                }
+            ]
+        else:
+            assert isinstance(observingModes, list) and (
+                len(observingModes) > 0
+            ), "No observing modes defined."
+
+        self.populate_observingModes(observingModes)
+
+        # populate fundamental IWA and OWA - the extrema of both values for all modes
+        IWAs = [
+            x.get("IWA").to(u.arcsec).value
+            for x in self.observingModes
+            if x.get("IWA") is not None
+        ]
+        if len(IWAs) > 0:
+            self.IWA = min(IWAs) * u.arcsec
+        else:
+            self.IWA = float(IWA) * u.arcsec
+
+        OWAs = [
+            x.get("OWA").to(u.arcsec).value
+            for x in self.observingModes
+            if x.get("OWA") is not None
+        ]
+        if len(OWAs) > 0:
+            self.OWA = max(OWAs) * u.arcsec
+        else:
+            self.OWA = float(OWA) * u.arcsec if OWA != 0 else np.inf * u.arcsec
+
+        assert self.IWA < self.OWA, "Fundamental IWA must be smaller that the OWA."
+
+        # provide every observing mode with a unique identifier
+        self.genObsModeHex()
+
+    def __str__(self):
+        """String representation of the Optical System object
+
+        When the command 'print' is used on the Optical System object, this
+        method will print the attribute values contained in the object
+
+        """
+
+        for att in self.__dict__:
+            print("%s: %r" % (att, getattr(self, att)))
+
+        return "Optical System class object attributes"
+
+    def populate_scienceInstruments(self, scienceInstruments):
+        """Helper method to parse input scienceInstrument dictionaries and assign
+        default values, as needed. Also creates the allowed_scienceInstrument_kws
+        attribute.
+
+        Args:
+            scienceInstruments (list):
+                List of scienceInstrument dicts.
+
+        """
+
+        self.scienceInstruments = copy.deepcopy(scienceInstruments)
         self._outspec["scienceInstruments"] = []
+        instnames = []
+
         for ninst, inst in enumerate(self.scienceInstruments):
             assert isinstance(
                 inst, dict
             ), "Science instruments must be defined as dicts."
             assert "name" in inst and isinstance(
                 inst["name"], str
-            ), "All science instruments must have key name."
-            # populate with values that may be filenames (interpolants)
-            inst["QE"] = inst.get("QE", QE)
-            self._outspec["scienceInstruments"].append(inst.copy())
+            ), "All science instruments must have key 'name'."
+            instnames.append(inst["name"])
 
-            # quantum efficiency
+            # quantum efficiency can be a single number of a filename
+            inst["QE"] = inst.get("QE", self.default_vals["QE"])
+            self._outspec["scienceInstruments"].append(inst.copy())
             if isinstance(inst["QE"], str):
-                pth = os.path.normpath(os.path.expandvars(inst["QE"]))
-                assert os.path.isfile(pth), "%s is not a valid file." % pth
-                # Check csv vs fits
-                ext = pth.split(".")[-1]
-                assert ext == "fits" or ext == "csv", (
-                    "%s must be a fits or csv file." % pth
-                )
-                dat = self.get_param_data(pth)
-                lam, D = (
-                    (dat[0], dat[1]) if dat.shape[0] == 2 else (dat[:, 0], dat[:, 1])
+                # Load data and create interpolant
+                dat, hdr = self.get_param_data(
+                    inst["QE"],
+                    # left_col_name="lambda", # TODO: start enforcing these
+                    # param_name="QE",
+                    expected_ndim=2,
+                    expected_first_dim=2,
                 )
+                lam, D = (dat[0].astype(float), dat[1].astype(float))
                 assert np.all(D >= 0) and np.all(
                     D <= 1
-                ), "QE must be positive and smaller than 1."
+                ), "All QE values must be positive and smaller than 1."
+                if isinstance(hdr, fits.Header):
+                    if "UNITS" in hdr:
+                        lam = ((lam * u.Unit(hdr["UNITS"])).to(u.nm)).value
+
                 # parameter values outside of lam
                 Dinterp1 = scipy.interpolate.interp1d(
-                    lam.astype(float),
-                    D.astype(float),
+                    lam,
+                    D,
                     kind="cubic",
                     fill_value=0.0,
                     bounds_error=False,
@@ -459,75 +539,85 @@ def __init__(
                     / u.photon
                 )
             else:
-                inst["QE"] = QE
-                self.vprint("Anomalous input, value set to default.")
-
-            # load detector specifications
-            inst["optics"] = float(
-                inst.get("optics", optics)
-            )  # attenuation due to optics
-            inst["FoV"] = float(inst.get("FoV", FoV)) * u.arcsec  # field of view
-            inst["pixelNumber"] = int(
-                inst.get("pixelNumber", pixelNumber)
-            )  # array format
-            inst["pixelSize"] = (
-                float(inst.get("pixelSize", pixelSize)) * u.m
-            )  # pixel pitch
-            inst["pixelScale"] = (
-                inst.get("pixelScale", 2 * inst["FoV"].value / inst["pixelNumber"])
-                * u.arcsec
-            )  # pixel pitch
-            inst["idark"] = float(inst.get("idark", idark)) / u.s  # dark-current rate
-            inst["CIC"] = float(inst.get("CIC", CIC))  # clock-induced-charge
-            inst["sread"] = float(inst.get("sread", sread))  # effective readout noise
-            inst["texp"] = (
-                float(inst.get("texp", texp)) * u.s
-            )  # exposure time per frame
-            inst["ENF"] = float(inst.get("ENF", ENF))  # excess noise factor
-            inst["PCeff"] = float(
-                inst.get("PCeff", PCeff)
-            )  # photon counting efficiency
-            inst["k_samp"] = float(
-                inst.get("k_samp", k_samp)
-            )  # coronagraph intrinsic sampling
-            inst["kRN"] = float(inst.get("kRN", kRN))  # read noise
-            inst["lam_d"] = float(inst.get("lam_d", lam_d)) * u.nm  # design wavelength
-            inst["lam_c"] = (
-                float(inst.get("lam_c", lam_c)) * u.nm
-            )  # critical wavelength
-            inst["CTE_derate"] = float(
-                inst.get("CTE_derate", CTE_derate)
-            )  # charge transfer efficiency derate
-            inst["dark_derate"] = float(
-                inst.get("dark_derate", dark_derate)
-            )  # dark noise derate
-            inst["refl_derate"] = float(
-                inst.get("refl_derate", refl_derate)
-            )  # reflectivity derate
-            inst["MUF_thruput"] = float(
-                inst.get("MUF_thruput", MUF_thruput)
-            )  # core model uncertainty throughput
+                inst["QE"] = self.default_vals["QE"]
+                warnings.warn(
+                    (
+                        "QE input is not string or number for instrument "
+                        f" {inst['name']}. Value set to default."
+                    )
+                )
+
+            # load all required detector specifications
+            # specify dictionary of keys and units
+            kws = {
+                "optics": None,  # attenuation due to instrument optics
+                "FoV": u.arcsec,  # angular half-field of view of instrument
+                "pixelNumber": None,  # array format
+                "pixelSize": u.m,  # pixel pitch
+                "pixelScale": u.arcsec,  # pixel scale (angular IFOV)
+                "idark": 1 / u.s,  # dark-current rate
+                "sread": None,  # effective readout noise
+                "texp": u.s,  # default exposure time per frame
+            }
+
+            for kw in kws:
+                inst[kw] = float(inst.get(kw, self.default_vals[kw]))
+                if kws[kw] is not None:
+                    inst[kw] *= kws[kw]
+
+            # start tracking allowed_scienceInstrument_kws
+            self.allowed_scienceInstrument_kws = ["name", "QE"] + list(kws.keys())
+
+            # do some basic consistency checking on pixelScale and FoV:
+            predFoV = np.arctan(inst["pixelNumber"] * np.tan(inst["pixelScale"] / 2))
+            # generate warning if FoV is larger than prediction (but allow for
+            # approximate equality)
+            if (inst["FoV"] > predFoV) and not (np.isclose(inst["FoV"], predFoV)):
+                warnings.warn(
+                    f'Input FoV ({inst["FoV"]}) is larger than FoV computed '
+                    f"from pixelScale ({predFoV.to(u.arcsec) :.2f}) for "
+                    f'instrument {inst["name"]}. This feels like a mistkae.'
+                )
 
             # parameters specific to spectrograph
             if "spec" in inst["name"].lower():
                 # spectral resolving power
-                inst["Rs"] = float(inst.get("Rs", Rs))
+                inst["Rs"] = float(inst.get("Rs", self.default_vals["Rs"]))
                 # lenslet sampling, number of pixel per lenslet rows or cols
-                inst["lenslSamp"] = float(inst.get("lenslSamp", lenslSamp))
-                # lenslets in core
-                inst["Nlensl"] = float(inst.get("Nlensl", Nlensl))
+                inst["lenslSamp"] = float(
+                    inst.get("lenslSamp", self.default_vals["lenslSamp"])
+                )
             else:
                 inst["Rs"] = 1.0
                 inst["lenslSamp"] = 1.0
-                inst["Nlensl"] = 5.0
 
-            # calculate focal and f-number
-            inst["focal"] = (
-                inst["pixelSize"].to("m") / inst["pixelScale"].to("rad").value
-            )
-            inst["fnumber"] = float(inst["focal"] / self.pupilDiam)
+            self.allowed_scienceInstrument_kws += ["Rs", "lenslSamp"]
+
+            # calculate focal length and f-number as needed
+            if "focal" in inst:
+                inst["focal"] = float(inst["focal"]) * u.m
+                inst["fnumber"] = float(inst["focal"] / self.pupilDiam)
+            elif ("fnumber") in inst:
+                inst["fnumber"] = float(inst["fnumber"])
+                inst["focal"] = inst["fnumber"] * self.pupilDiam
+            else:
+                inst["focal"] = (
+                    inst["pixelSize"] / 2 / np.tan(inst["pixelScale"] / 2)
+                ).to(u.m)
+                inst["fnumber"] = float(inst["focal"] / self.pupilDiam)
+
+            self.allowed_scienceInstrument_kws += ["focal", "fnumber"]
+
+            # consistency check parameters
+            predFocal = (inst["pixelSize"] / 2 / np.tan(inst["pixelScale"] / 2)).to(u.m)
+            if not (np.isclose(predFocal.value, inst["focal"].to(u.m).value)):
+                warnings.warn(
+                    f'Input focal length ({inst["focal"] :.2f}) does not '
+                    f"match value from pixelScale ({predFocal :.2f}) for "
+                    f'instrument {inst["name"]}. This feels like a mistkae.'
+                )
 
-            # populate detector specifications to outspec
+            # populate updated detector specifications to outspec
             for att in inst:
                 if att not in ["QE"]:
                     dat = inst[att]
@@ -535,35 +625,99 @@ def __init__(
                         dat.value if isinstance(dat, u.Quantity) else dat
                     )
 
-        # loop through all starlight suppression systems (must have one defined)
-        assert isinstance(starlightSuppressionSystems, list) and (
-            len(starlightSuppressionSystems) > 0
-        ), "No starlight suppression systems defined."
-        self.starlightSuppressionSystems = starlightSuppressionSystems
+        # ensure that all instrument names are unique:
+        assert (
+            len(instnames) == np.unique(instnames).size
+        ), "Instrument names muse be unique."
+
+        # call additional instrument setup
+        self.populate_scienceInstruments_extra()
+
+    def populate_scienceInstruments_extra(self):
+        """Additional setup for science instruments.  This is intended for overloading
+        in downstream implementations and is intentionally left blank in the prototype.
+        """
+
+        pass
+
+    def populate_starlightSuppressionSystems(self, starlightSuppressionSystems):
+        """Helper method to parse input starlightSuppressionSystem dictionaries and
+        assign default values, as needed. Also creates the
+        allowed_starlightSuppressionSystem_kws attribute.
+
+        Args:
+            starlightSuppressionSystems (list):
+                List of starlightSuppressionSystem dicts.
+
+        """
+
+        self.starlightSuppressionSystems = copy.deepcopy(starlightSuppressionSystems)
         self.haveOcculter = False
         self._outspec["starlightSuppressionSystems"] = []
+        systnames = []
+
         for nsyst, syst in enumerate(self.starlightSuppressionSystems):
             assert isinstance(
                 syst, dict
             ), "Starlight suppression systems must be defined as dicts."
             assert "name" in syst and isinstance(
                 syst["name"], str
-            ), "All starlight suppression systems must have key name."
-            # populate with values that may be filenames (interpolants)
-            syst["occ_trans"] = syst.get("occ_trans", occ_trans)
-            syst["core_thruput"] = syst.get("core_thruput", core_thruput)
-            syst["core_contrast"] = syst.get("core_contrast", core_contrast)
-            syst["core_mean_intensity"] = syst.get(
-                "core_mean_intensity", core_thruput * core_contrast
+            ), "All starlight suppression systems must have key 'name'."
+            systnames.append(syst["name"])
+
+            # determine system wavelength (lam), bandwidth (deltaLam) and bandwidth
+            # fraction (BW)
+            # use deltaLam if given, otherwise use BW
+            syst["lam"] = float(syst.get("lam", self.default_vals["lam"])) * u.nm
+            syst["deltaLam"] = (
+                float(
+                    syst.get(
+                        "deltaLam",
+                        syst["lam"].to("nm").value
+                        * syst.get("BW", self.default_vals["BW"]),
+                    )
+                )
+                * u.nm
             )
-            syst["core_area"] = syst.get(
-                "core_area", 0.0
-            )  # if zero, will get from lam/D
-            syst["PSF"] = syst.get("PSF", PSF)
-            self._outspec["starlightSuppressionSystems"].append(syst.copy())
+            syst["BW"] = float(syst["deltaLam"] / syst["lam"])
+
+            # populate all required default_vals
+            names = [
+                "occ_trans",
+                "core_thruput",
+                "core_platescale",
+                "input_angle_units",
+                "core_platescale_units",
+                "contrast_floor",
+                "csv_angsep_colname",
+            ]
+            # fill contrast from default only if core_mean_intensity not set
+            if "core_mean_intensity" not in syst:
+                names.append("core_contrast")
+            for n in names:
+                syst[n] = syst.get(n, self.default_vals[n])
+
+            # start tracking allowed keywords
+            self.allowed_starlightSuppressionSystem_kws = [
+                "name",
+                "lam",
+                "deltaLam",
+                "BW",
+                "core_mean_intensity",
+                "optics",
+                "occulter",
+                "ohTime",
+                "core_platescale",
+                "IWA",
+                "OWA",
+                "core_area",
+            ]
+            self.allowed_starlightSuppressionSystem_kws += names
+            if "core_contrast" not in self.allowed_starlightSuppressionSystem_kws:
+                self.allowed_starlightSuppressionSystem_kws.append("core_contrast")
 
-            # attenuation due to optics specific to the coronagraph (defaults to 1)
-            # e.g. polarizer, Lyot stop, extra flat mirror
+            # attenuation due to optics specific to the coronagraph not caputred by the
+            # coronagraph throughput curves. Defaults to 1.
             syst["optics"] = float(syst.get("optics", 1.0))
 
             # set an occulter, for an external or hybrid system
@@ -571,71 +725,143 @@ def __init__(
             if syst["occulter"]:
                 self.haveOcculter = True
 
-            # handle inf OWA
-            if syst.get("OWA") == 0:
-                syst["OWA"] = np.Inf
+            # copy system definition to outspec
+            self._outspec["starlightSuppressionSystems"].append(syst.copy())
 
-            # when provided, always use deltaLam instead of BW (bandwidth fraction)
-            syst["lam"] = float(syst.get("lam", lam)) * u.nm  # central wavelength (nm)
-            syst["deltaLam"] = (
-                float(
-                    syst.get(
-                        "deltaLam", syst["lam"].to("nm").value * syst.get("BW", BW)
+            # now we populate everything that has units
+
+            # overhead time:
+            syst["ohTime"] = (
+                float(syst.get("ohTime", self.default_vals["ohTime"])) * u.d
+            )
+
+            # figure out the angle unit we're assuming for all inputs
+            syst["input_angle_unit_value"] = self.get_angle_unit_from_header(None, syst)
+
+            # if platescale was set, give it units
+            if syst["core_platescale"] is not None:
+                # check for units to use
+                if (syst["core_platescale_units"] is None) or (
+                    syst["core_platescale_units"] in ["unitless", "LAMBDA/D"]
+                ):
+                    platescale_unit = (syst["lam"] / self.pupilDiam).to(
+                        u.arcsec, equivalencies=u.dimensionless_angles()
                     )
+                else:
+                    platescale_unit = 1 * u.Unit(syst["core_platescale_units"])
+
+                syst["core_platescale"] = (
+                    syst["core_platescale"] * platescale_unit
+                ).to(u.arcsec)
+
+            # if IWA/OWA are given, assign them units (otherwise they'll be set from
+            # table data or defaults (whichever comes first).
+            if "IWA" in syst:
+                syst["IWA"] = (float(syst["IWA"]) * syst["input_angle_unit_value"]).to(
+                    u.arcsec
+                )
+            if "OWA" in syst:
+                # Zero OWA aliased to inf OWA
+                if (syst["OWA"] == 0) or (syst["OWA"] == np.Inf):
+                    syst["OWA"] = np.Inf * u.arcsec
+                else:
+                    syst["OWA"] = (
+                        float(syst["OWA"]) * syst["input_angle_unit_value"]
+                    ).to(u.arcsec)
+
+            # get the system's keepout angles
+            names = [
+                "koAngles_Sun",
+                "koAngles_Earth",
+                "koAngles_Moon",
+                "koAngles_Small",
+            ]
+            for n in names:
+                syst[n] = [float(x) for x in syst.get(n, self.default_vals[n])] * u.deg
+
+            self.allowed_starlightSuppressionSystem_kws += names
+
+            # now we're going to populate everything that's callable
+
+            # first let's handle core mean intensity
+            if "core_mean_intensity" in syst:
+                syst = self.get_core_mean_intensity(syst)
+
+                # ensure that platescale has also been set
+                assert syst["core_platescale"] is not None, (
+                    f"In system {syst['name']}, core_mean_intensity "
+                    "is set, but core_platescale is not.  This is not allowed."
                 )
-                * u.nm
-            )  # bandwidth (nm)
-            syst["BW"] = float(syst["deltaLam"] / syst["lam"])  # bandwidth fraction
-            # default lam and BW updated with values from first instrument
-
-            if nsyst == 0:
-                lam, BW = syst.get("lam").value, syst.get("BW")
-
-            # get keepout angles for specific instrument
-            syst["koAngles_Sun"] = [
-                float(x) for x in syst.get("koAngles_Sun", koAngles_Sun)
-            ] * u.deg
-            syst["koAngles_Earth"] = [
-                float(x) for x in syst.get("koAngles_Earth", koAngles_Earth)
-            ] * u.deg
-            syst["koAngles_Moon"] = [
-                float(x) for x in syst.get("koAngles_Moon", koAngles_Moon)
-            ] * u.deg
-            syst["koAngles_Small"] = [
-                float(x) for x in syst.get("koAngles_Small", koAngles_Small)
-            ] * u.deg
-
-            # get coronagraph input parameters
-            syst = self.get_coro_param(syst, "occ_trans")
-            syst = self.get_coro_param(syst, "core_thruput")
-            syst = self.get_coro_param(syst, "core_contrast", fill=1.0)
-            syst = self.get_coro_param(syst, "core_mean_intensity")
-            syst = self.get_coro_param(syst, "core_area")
-            syst["core_platescale"] = syst.get("core_platescale", core_platescale)
-            syst["contrast_floor"] = syst.get("contrast_floor", contrast_floor)
-
-            # get PSF
-            if isinstance(syst["PSF"], str):
-                pth = os.path.normpath(os.path.expandvars(syst["PSF"]))
-                assert os.path.isfile(pth), "%s is not a valid file." % pth
-                with fits.open(pth) as f:
-                    hdr = f[0].header  # noqa: F841
-                    dat = f[0].data
-                assert len(dat.shape) == 2, "Wrong PSF data shape."
-                assert np.any(dat), "PSF must be != 0"
-                syst["PSF"] = lambda l, s, P=dat: P
             else:
-                assert np.any(syst["PSF"]), "PSF must be != 0"
-                syst["PSF"] = lambda l, s, P=np.array(syst["PSF"]).astype(float): P
-
-            # loading system specifications
-            syst["IWA"] = (
-                syst.get("IWA", 0.1 if IWA is None else IWA) * u.arcsec
-            )  # inner WA
-            syst["OWA"] = (
-                syst.get("OWA", np.Inf if OWA is None else OWA) * u.arcsec
-            )  # outer WA
-            syst["ohTime"] = float(syst.get("ohTime", ohTime)) * u.d  # overhead time
+                syst["core_mean_intensity"] = None
+
+            if "core_contrast" in syst:
+                syst = self.get_coro_param(
+                    syst,
+                    "core_contrast",
+                    fill=1.0,
+                    expected_ndim=2,
+                    expected_first_dim=2,
+                    min_val=0.0,
+                )
+            else:
+                syst["core_contrast"] = None
+
+            # now get the throughputs
+            syst = self.get_coro_param(
+                syst,
+                "occ_trans",
+                expected_ndim=2,
+                expected_first_dim=2,
+                min_val=0.0,
+                max_val=(np.inf if syst["occulter"] else 1.0),
+            )
+            syst = self.get_coro_param(
+                syst,
+                "core_thruput",
+                expected_ndim=2,
+                expected_first_dim=2,
+                min_val=0.0,
+                max_val=(np.inf if syst["occulter"] else 1.0),
+            )
+
+            # finally, for core_area, if none is supplied, then set to area of
+            # \sqrt{2}/2 lambda/D radius aperture
+            if (
+                ("core_area" not in syst)
+                or (syst["core_area"] is None)
+                or (syst["core_area"] == 0)
+            ):
+                # need to put this in the proper unit
+                angunit = self.get_angle_unit_from_header(None, syst)
+
+                syst["core_area"] = (
+                    (
+                        (np.pi / 2)
+                        * (syst["lam"] / self.pupilDiam).to(
+                            u.arcsec, equivalencies=u.dimensionless_angles()
+                        )
+                        ** 2
+                        / angunit**2
+                    )
+                    .decompose()
+                    .value
+                )
+            syst = self.get_coro_param(
+                syst,
+                "core_area",
+                expected_ndim=2,
+                expected_first_dim=2,
+                min_val=0.0,
+            )
+
+            # at this point, we must have set an IWA/OWA, but lets make sure
+            for key in ["IWA", "OWA"]:
+                assert (
+                    (key in syst)
+                    and isinstance(syst[key], u.Quantity)
+                    and (syst[key].unit == u.arcsec)
+                ), f"{key} not found or has the wrong unit in system {syst['name']}."
 
             # populate system specifications to outspec
             for att in syst:
@@ -645,50 +871,64 @@ def __init__(
                     "core_contrast",
                     "core_mean_intensity",
                     "core_area",
-                    "PSF",
-                    "F0",
+                    "input_angle_unit_value",
+                    "IWA",
+                    "OWA",
                 ]:
                     dat = syst[att]
                     self._outspec["starlightSuppressionSystems"][nsyst][att] = (
                         dat.value if isinstance(dat, u.Quantity) else dat
                     )
 
-        # loop through all observing modes
-        # if no observing mode defined, create a default mode
-        if observingModes is None:
-            inst = self.scienceInstruments[0]
-            syst = self.starlightSuppressionSystems[0]
-            observingModes = [
-                {
-                    "detectionMode": True,
-                    "instName": inst["name"],
-                    "systName": syst["name"],
-                }
-            ]
+        # ensure that all starlight suppression system names are unique:
+        assert (
+            len(systnames) == np.unique(systnames).size
+        ), "Starlight suppression system names muse be unique."
+
+        # call additional setup
+        self.populate_starlightSuppressionSystems_extra()
+
+    def populate_starlightSuppressionSystems_extra(self):
+        """Additional setup for starlight suppression systems.  This is intended for
+        overloading in downstream implementations and is intentionally left blank in
+        the prototype.
+        """
+
+        pass
+
+    def populate_observingModes(self, observingModes):
+        """Helper method to parse input observingMode dictionaries and assign default
+        values, as needed. Also creates the allowed_observingMode_kws attribute.
+
+        Args:
+            observingModes (list):
+                List of observingMode dicts.
+
+        """
+
         self.observingModes = observingModes
         self._outspec["observingModes"] = []
         for nmode, mode in enumerate(self.observingModes):
             assert isinstance(mode, dict), "Observing modes must be defined as dicts."
             assert (
                 "instName" in mode and "systName" in mode
-            ), "All observing modes must have key instName and systName."
+            ), "All observing modes must have keys 'instName' and 'systName'."
             assert np.any(
                 [mode["instName"] == inst["name"] for inst in self.scienceInstruments]
-            ), ("The mode's instrument name " + mode["instName"] + " does not exist.")
+            ), f"The mode's instrument name {mode['instName']} does not exist."
             assert np.any(
                 [
                     mode["systName"] == syst["name"]
                     for syst in self.starlightSuppressionSystems
                 ]
-            ), ("The mode's system name " + mode["systName"] + " does not exist.")
+            ), f"The mode's system name {mode['systName']} does not exist."
             self._outspec["observingModes"].append(mode.copy())
-            # create temporary placeholder so we don't have to look up the original
-            # mode again. alphabetically sort keys to ensure generality
-            mode["hex"] = dictToSortedStr(mode)
 
             # loading mode specifications
-            mode["SNR"] = float(mode.get("SNR", SNR))
-            mode["timeMultiplier"] = float(mode.get("timeMultiplier", timeMultiplier))
+            mode["SNR"] = float(mode.get("SNR", self.default_vals["SNR"]))
+            mode["timeMultiplier"] = float(
+                mode.get("timeMultiplier", self.default_vals["timeMultiplier"])
+            )
             mode["detectionMode"] = mode.get("detectionMode", False)
             mode["inst"] = [
                 inst
@@ -700,6 +940,21 @@ def __init__(
                 for syst in self.starlightSuppressionSystems
                 if syst["name"] == mode["systName"]
             ][0]
+
+            # start tracking allowed keywords
+            self.allowed_observingMode_kws = [
+                "instName",
+                "systName",
+                "SNR",
+                "timeMultiplier",
+                "detectionMode",
+                "lam",
+                "deltaLam",
+                "BW",
+                "bandpass_model",
+                "bandpass_step",
+            ]
+
             # get mode wavelength and bandwidth (get system's values by default)
             # when provided, always use deltaLam instead of BW (bandwidth fraction)
             syst_lam = mode["syst"]["lam"].to("nm").value
@@ -710,21 +965,96 @@ def __init__(
                 * u.nm
             )
             mode["BW"] = float(mode["deltaLam"] / mode["lam"])
-            # get mode IWA and OWA: rescale if the mode wavelength is different than
+
+            # get mode IWA and OWA: rescale if the mode wavelength is different from
             # the wavelength at which the system is defined
             mode["IWA"] = mode["syst"]["IWA"]
             mode["OWA"] = mode["syst"]["OWA"]
             if mode["lam"] != mode["syst"]["lam"]:
                 mode["IWA"] = mode["IWA"] * mode["lam"] / mode["syst"]["lam"]
                 mode["OWA"] = mode["OWA"] * mode["lam"] / mode["syst"]["lam"]
-            # radiation dosage, goes from 0 (beginning of mission) to 1 (end of mission)
-            mode["radDos"] = float(mode.get("radDos", radDos))
-            mode["ContrastScenario"] = mode.get("ContrastScenario", ContrastScenario)
+
+            # OWA must be bounded by FOV:
+            if mode["OWA"] > mode["inst"]["FoV"]:
+                mode["OWA"] = mode["inst"]["FoV"]
+
+            # generate the mode's bandpass
+            # TODO: Add support for custom filter profiles
+            mode["bandpass_model"] = mode.get(
+                "bandpass_model", self.default_vals["bandpass_model"]
+            ).lower()
+            assert mode["bandpass_model"] in [
+                "gaussian",
+                "box",
+            ], "bandpass_model must be one of ['gaussian', 'box']"
+            mode["bandpass_step"] = (
+                float(mode.get("bandpass_step", self.default_vals["bandpass_step"]))
+                * u.nm
+            )
+            if mode["bandpass_model"] == "box":
+                mode["bandpass"] = SpectralElement(
+                    Box1D,
+                    x_0=mode["lam"],
+                    width=mode["deltaLam"],
+                    step=mode["bandpass_step"].to(u.AA).value,
+                )
+            else:
+                mode["bandpass"] = SpectralElement(
+                    Gaussian1D,
+                    mean=mode["lam"],
+                    stddev=mode["deltaLam"] / np.sqrt(2 * np.pi),
+                )
+
+            # check for out of range wavelengths
+            # currently capped to 10 um
+            assert (
+                mode["bandpass"].waveset.max() < 10 * u.um
+            ), "Bandpasses beyond 10 um are not supported."
+
+            # evaluate zero-magnitude flux for this band from vega spectrum
+            # NB: This is flux, not flux density! The bandpass is already factored in.
+            mode["F0"] = Observation(
+                self.vega_spectrum, mode["bandpass"], force="taper"
+            ).integrate()
+
+            # populate system specifications to outspec
+            for att in mode:
+                if att not in [
+                    "inst",
+                    "syst",
+                    "F0",
+                    "bandpass",
+                ]:
+                    dat = mode[att]
+                    self._outspec["observingModes"][nmode][att] = (
+                        dat.value if isinstance(dat, u.Quantity) else dat
+                    )
+
+            # populate some final mode attributes (computed from the others)
+            # define total mode attenution
+            mode["attenuation"] = mode["inst"]["optics"] * mode["syst"]["optics"]
+
+            # effective mode bandwidth (including any IFS spectral resolving power)
+            mode["deltaLam_eff"] = (
+                mode["lam"] / mode["inst"]["Rs"]
+                if "spec" in mode["inst"]["name"].lower()
+                else mode["deltaLam"]
+            )
+
+            # total attenuation due to non-coronagraphic optics:
+            mode["losses"] = (
+                self.pupilArea
+                * mode["inst"]["QE"](mode["lam"])
+                * mode["attenuation"]
+                * mode["deltaLam_eff"]
+                / mode["deltaLam"]
+            )
 
         # check for only one detection mode
         allModes = self.observingModes
         detModes = list(filter(lambda mode: mode["detectionMode"], allModes))
         assert len(detModes) <= 1, "More than one detection mode specified."
+
         # if not specified, default detection mode is first imager mode
         if len(detModes) == 0:
             imagerModes = list(
@@ -736,53 +1066,14 @@ def __init__(
             else:
                 allModes[0]["detectionMode"] = True
 
-        # populate fundamental IWA and OWA as required
-        IWAs = [x.get("IWA") for x in self.observingModes if x.get("IWA") is not None]
-        if IWA is not None:
-            self.IWA = float(IWA) * u.arcsec
-        elif IWAs:
-            self.IWA = min(IWAs)
-        else:
-            raise ValueError("Could not determine fundamental IWA.")
-
-        OWAs = [x.get("OWA") for x in self.observingModes if x.get("OWA") is not None]
-        if OWA is not None:
-            self.OWA = float(OWA) * u.arcsec if OWA != 0 else np.inf * u.arcsec
-        elif OWAs:
-            self.OWA = max(OWAs)
-        else:
-            raise ValueError("Could not determine fundamental OWA.")
-
-        assert self.IWA < self.OWA, "Fundamental IWA must be smaller that the OWA."
-
-        # if binary leakage model provided, let's grab that as well
-        if binaryleakfilepath is not None:
-            assert os.path.exists(binaryleakfilepath), (
-                "Binary leakage model data file not found at %s" % binaryleakfilepath
-            )
-
-            binaryleakdata = np.genfromtxt(binaryleakfilepath, delimiter=",")
-
-            self.binaryleakmodel = scipy.interpolate.interp1d(
-                binaryleakdata[:, 0], binaryleakdata[:, 1], bounds_error=False
-            )
-            self._outspec["binaryleakfilepath"] = binaryleakfilepath
-
-        # provide every observing mode with a unique identifier
-        self.genObsModeHex()
-
-    def __str__(self):
-        """String representation of the Optical System object
-
-        When the command 'print' is used on the Optical System object, this
-        method will print the attribute values contained in the object
+        self.populate_observingModes_extra()
 
+    def populate_observingModes_extra(self):
+        """Additional setup for observing modes  This is intended for overloading in
+        downstream implementations and is intentionally left blank in the prototype.
         """
 
-        for att in self.__dict__:
-            print("%s: %r" % (att, getattr(self, att)))
-
-        return "Optical System class object attributes"
+        pass
 
     def genObsModeHex(self):
         """Generate a unique hash for every observing mode to be used in downstream
@@ -792,7 +1083,7 @@ def genObsModeHex(self):
         instrument and its starlight suppression system.
         """
 
-        for mode in self.observingModes:
+        for nmode, mode in enumerate(self.observingModes):
             inst = [
                 inst
                 for inst in self._outspec["scienceInstruments"]
@@ -805,14 +1096,344 @@ def genObsModeHex(self):
             ][0]
 
             modestr = "{},{},{}".format(
-                mode["hex"], dictToSortedStr(inst), dictToSortedStr(syst)
+                dictToSortedStr(self._outspec["observingModes"][nmode]),
+                dictToSortedStr(inst),
+                dictToSortedStr(syst),
             )
 
             mode["hex"] = genHexStr(modestr)
 
-    def get_coro_param(self, syst, param_name, fill=0.0):
+    def get_core_mean_intensity(
+        self,
+        syst,
+    ):
+        """Load and process core_mean_intensity data
+
+        Args:
+            syst (dict):
+                Dictionary containing the parameters of one starlight suppression system
+
+        Returns:
+            dict:
+                Updated dictionary of starlight suppression system parameters
+
+        """
+
+        param_name = "core_mean_intensity"
+        fill = 1.0
+        assert param_name in syst, f"{param_name} not found in syst."
+        if isinstance(syst[param_name], str):
+            dat, hdr = self.get_param_data(
+                syst[param_name],
+                left_col_name=syst["csv_angsep_colname"],
+                param_name=param_name,
+                expected_ndim=2,
+            )
+            dat = dat.transpose()  # flip such that data is in rows
+            WA, D = dat[0].astype(float), dat[1:].astype(float)
+
+            # check values as needed
+            assert np.all(
+                D > 0
+            ), f"{param_name} in {syst['name']} must be >0 everywhere."
+
+            # get angle unit scale WA
+            angunit = self.get_angle_unit_from_header(hdr, syst)
+            WA = (WA * angunit).to(u.arcsec).value
+
+            # get platescale from header (if this is a FITS header)
+            if isinstance(hdr, fits.Header) and ("PIXSCALE" in hdr):
+                # use the header unit preferentially. otherwise drop back to the
+                # core_platescale_units keyword
+                if "UNITS" in hdr:
+                    platescale = (float(hdr["PIXSCALE"]) * angunit).to(u.arcsec)
+                else:
+                    if (syst["core_platescale_units"] is None) or (
+                        syst["core_platescale_units"] in ["unitless", "LAMBDA/D"]
+                    ):
+                        platescale_unit = (syst["lam"] / self.pupilDiam).to(
+                            u.arcsec, equivalencies=u.dimensionless_angles()
+                        )
+                    else:
+                        platescale_unit = 1 * u.Unit(syst["core_platescale_units"])
+                    platescale = (float(hdr["PIXSCALE"]) * platescale_unit).to(u.arcsec)
+
+                if (syst.get("core_platescale") is not None) and (
+                    syst["core_platescale"] != platescale
+                ):
+                    warnings.warn(
+                        "platescale for core_mean_intensity in system "
+                        f"{syst['name']} does not match input value.  "
+                        "Overwriting with value from FITS file but you "
+                        "should check your inputs."
+                    )
+                syst["core_platescale"] = platescale
+
+            # handle case where only one data row is present
+            if D.shape[0] == 1:
+                D = np.squeeze(D)
+
+                # table interpolate function
+                Dinterp = scipy.interpolate.interp1d(
+                    WA,
+                    D,
+                    kind="linear",
+                    fill_value=fill,
+                    bounds_error=False,
+                )
+                # create a callable lambda function. for coronagraphs, we need to scale
+                # the angular separation by wavelength, but for occulters we just need
+                # to ensure that we're within the wavelength range
+                if syst["occulter"]:
+                    minl = syst["lam"] - syst["deltaLam"] / 2
+                    maxl = syst["lam"] + syst["deltaLam"] / 2
+                    syst[param_name] = (
+                        lambda lam, s, d=0 * u.arcsec, Dinterp=Dinterp, minl=minl, maxl=maxl, fill=fill: (  # noqa: E501
+                            np.array(Dinterp(s.to("arcsec").value), ndmin=1) - fill
+                        )
+                        * np.array((minl < lam) & (lam < maxl), ndmin=1).astype(int)
+                        + fill
+                    )
+                else:
+                    syst[
+                        param_name
+                    ] = lambda lam, s, d=0 * u.arcsec, Dinterp=Dinterp, lam0=syst[
+                        "lam"
+                    ]: np.array(
+                        Dinterp((s * lam0 / lam).to("arcsec").value), ndmin=1
+                    )
+
+            # and now the general case of multiple rows
+            else:
+                # grab stellar diameters from header info
+                diams = np.zeros(len(D))
+                # FITS files
+                if isinstance(hdr, fits.Header):
+                    for j in range(len(D)):
+                        k = f"DIAM{j :03d}"
+                        assert k in hdr, (
+                            f"Expected keyword {k} not found in header "
+                            f"of file {syst[param_name]} for system "
+                            f"{syst['name']}"
+                        )
+                        diams[j] = float(hdr[k])
+                # TODO: support for CSV files
+                else:
+                    raise NotImplementedError(
+                        "No CSV support for 2D core_mean_intensity"
+                    )
+
+                # determine units and convert as needed
+                diams = (diams * angunit).to(u.arcsec).value
+
+                Dinterp = scipy.interpolate.RegularGridInterpolator(
+                    (WA, diams), D.transpose(), bounds_error=False, fill_value=1.0
+                )
+
+                # create a callable lambda function. for coronagraphs, we need to scale
+                # the angular separation and stellar diameter by wavelength, but for
+                # occulters we just need to ensure that we're within the wavelength
+                # range
+                if syst["occulter"]:
+                    minl = syst["lam"] - syst["deltaLam"] / 2
+                    maxl = syst["lam"] + syst["deltaLam"] / 2
+                    syst[param_name] = (
+                        lambda lam, s, d=0 * u.arcsec, Dinterp=Dinterp, minl=minl, maxl=maxl, fill=fill: (  # noqa: E501
+                            np.array(
+                                Dinterp((s.to("arcsec").value, d.to("arcsec").value)),
+                                ndmin=1,
+                            )
+                            - fill
+                        )
+                        * np.array((minl < lam) & (lam < maxl), ndmin=1).astype(int)
+                        + fill
+                    )
+                else:
+                    syst[
+                        param_name
+                    ] = lambda lam, s, d=0 * u.arcsec, Dinterp=Dinterp, lam0=syst[
+                        "lam"
+                    ]: np.array(
+                        Dinterp(
+                            (
+                                (s * lam0 / lam).to("arcsec").value,
+                                (d * lam0 / lam).to("arcsec").value,
+                            )
+                        ),
+                        ndmin=1,
+                    )
+
+            # update IWA/OWA in system as needed
+            syst = self.update_syst_WAs(syst, WA, param_name)
+
+        elif isinstance(syst[param_name], numbers.Number):
+            # ensure paramter is within bounds
+            D = float(syst[param_name])
+            assert D > 0, f"{param_name} in {syst['name']} must be > 0."
+
+            # ensure you have values for IWA/OWA, otherwise use defaults
+            syst = self.update_syst_WAs(syst, None, None)
+            IWA = syst["IWA"].to(u.arcsec).value
+            OWA = syst["OWA"].to(u.arcsec).value
+
+            # same as for interpolant: coronagraphs scale with wavelength, occulters
+            # don't
+            if syst["occulter"]:
+                minl = syst["lam"] - syst["deltaLam"] / 2
+                maxl = syst["lam"] + syst["deltaLam"] / 2
+
+                syst[
+                    param_name
+                ] = lambda lam, s, d=0 * u.arcsec, D=D, IWA=IWA, OWA=OWA, minl=minl, maxl=maxl, fill=fill: (  # noqa: E501
+                    np.array(
+                        (IWA <= s.to("arcsec").value)
+                        & (s.to("arcsec").value <= OWA)
+                        & (minl < lam)
+                        & (lam < maxl),
+                        ndmin=1,
+                    ).astype(float)
+                    * (D - fill)
+                    + fill
+                )
+
+            else:
+                syst[param_name] = (
+                    lambda lam, s, d=0 * u.arcsec, D=D, lam0=syst[
+                        "lam"
+                    ], IWA=IWA, OWA=OWA, fill=fill: (
+                        np.array(
+                            (IWA <= (s * lam0 / lam).to("arcsec").value)
+                            & ((s * lam0 / lam).to("arcsec").value <= OWA),
+                            ndmin=1,
+                        ).astype(float)
+                    )
+                    * (D - fill)
+                    + fill
+                )
+        elif syst[param_name] is None:
+            syst[param_name] = None
+        else:
+            raise TypeError(
+                f"{param_name} for system {syst['name']} is neither a "
+                f"string nor a number. I don't know what to do with that."
+            )
+
+        return syst
+
+    def get_angle_unit_from_header(self, hdr, syst):
+        """Helper method. Extract angle unit from header, if it exists.
+
+        Args:
+            hdr (astropy.io.fits.header.Header or list):
+                FITS header for data or header row from CSV
+            syst (dict):
+                Dictionary containing the parameters of one starlight suppression system
+
+        Returns:
+            astropy.units.Unit:
+                The angle unit.
+        """
+        # if this is a FITS header, grab value from UNITS key if it exists
+        if isinstance(hdr, fits.Header) and ("UNITS" in hdr):
+            if hdr["UNITS"] in ["unitless", "LAMBDA/D"]:
+                angunit = (syst["lam"] / self.pupilDiam).to(
+                    u.arcsec, equivalencies=u.dimensionless_angles()
+                )
+            else:
+                angunit = 1 * u.Unit(hdr["UNITS"])
+        # otherwise, use the input_angle_units key
+        else:
+            # check if we've already computed this
+            if "input_angle_unit_value" in syst:
+                angunit = syst["input_angle_unit_value"]
+            else:
+                # if we're here, we have to do it from scratch
+                if (syst["input_angle_units"] is None) or (
+                    syst["input_angle_units"] in ["unitless", "LAMBDA/D"]
+                ):
+                    angunit = (syst["lam"] / self.pupilDiam).to(
+                        u.arcsec, equivalencies=u.dimensionless_angles()
+                    )
+                else:
+                    angunit = 1 * u.Unit(syst["input_angle_units"])
+
+        # final consistency check before returning
+        assert (
+            angunit.unit.physical_type == "angle"
+        ), f"Angle unit for system {syst['name']} is not an angle."
+
+        return angunit
+
+    def update_syst_WAs(self, syst, WA0, param_name):
+        """Helper method. Check system IWA/OWA and update from table
+        data, as needed. Alternatively, set from defaults.
+
+        Args:
+            syst (dict):
+                Dictionary containing the parameters of one starlight suppression system
+            WA0 (~numpy.ndarray, optional):
+                Array of angles from table data. Must be in arcseconds. If None, then
+                just set from defaults.
+            param_name (str, optional):
+                Name of parameter the table data belongs to. Must be set if WA is set.
+
+        Returns:
+            dict:
+                Updated dictionary of starlight suppression system parameters
+
+        """
+
+        # if WA not given, then we're going to be setting defaults, as needed.
+        if WA0 is None:
+            if "IWA" not in syst:
+                syst["IWA"] = (
+                    float(self.default_vals["IWA"]) * syst["input_angle_unit_value"]
+                ).to(u.arcsec)
+
+            if "OWA" not in syst:
+                syst["OWA"] = (
+                    float(self.default_vals["OWA"]) * syst["input_angle_unit_value"]
+                ).to(u.arcsec)
+
+            return syst
+
+        # otherwise, update IWA from table value
+        WA = WA0 * u.arcsec
+        if ("IWA" in syst) and (np.min(WA) > syst["IWA"]):
+            warnings.warn(
+                f"{param_name} has larger IWA than current system value "
+                f"for {syst['name']}. Updating to match table, but you "
+                "should check your inputs."
+            )
+            syst["IWA"] = np.min(WA)
+        elif "IWA" not in syst:
+            syst["IWA"] = np.min(WA)
+
+        # update OWA (if not an occulter)
+        if not (syst["occulter"]) and ("OWA" in syst) and (np.max(WA) < syst["OWA"]):
+            warnings.warn(
+                f"{param_name} has smaller OWA than current system "
+                f"value for {syst['name']}. Updating to match table, but "
+                "you should check your inputs."
+            )
+            syst["OWA"] = np.max(WA)
+        elif "OWA" not in syst:
+            syst["OWA"] = np.max(WA)
+
+        return syst
+
+    def get_coro_param(
+        self,
+        syst,
+        param_name,
+        fill=0.0,
+        expected_ndim=None,
+        expected_first_dim=None,
+        min_val=None,
+        max_val=None,
+    ):
         """For a given starlightSuppressionSystem, this method loads an input
-        parameter from a table (fits file) or a scalar value. It then creates a
+        parameter from a table (fits or csv file) or a scalar value. It then creates a
         callable lambda function, which depends on the wavelength of the system
         and the angular separation of the observed planet.
 
@@ -823,6 +1444,15 @@ def get_coro_param(self, syst, param_name, fill=0.0):
                 Name of the parameter that must be loaded
             fill (float):
                 Fill value for working angles outside of the input array definition
+            expected_ndim (int, optional):
+                Expected number of dimensions.  Only checked if not None. Defaults None.
+            expected_first_dim (int, optional):
+                Expected size of first dimension of data.  Only checked if not None.
+                Defaults None
+            min_val (float, optional):
+                Minimum allowed value of parameter. Defaults to None (no check).
+            max_val (float, optional):
+                Maximum allowed value of paramter. Defaults to None (no check).
 
         Returns:
             dict:
@@ -840,99 +1470,278 @@ def get_coro_param(self, syst, param_name, fill=0.0):
 
         """
 
-        assert isinstance(param_name, str), "param_name must be a string."
+        assert param_name in syst, f"{param_name} not found in system {syst['name']}."
         if isinstance(syst[param_name], str):
-            pth = os.path.normpath(os.path.expandvars(syst[param_name]))
-            assert os.path.isfile(pth), "%s is not a valid file." % pth
-            # Check for fits or csv file
-            ext = pth.split(".")[-1]
-            assert ext == "fits" or ext == "csv", "%s must be a fits or csv file." % pth
-            dat = self.get_param_data(pth, left_col_name="r_as", param_name=param_name)
-            WA, D = (dat[0], dat[1]) if dat.shape[0] == 2 else (dat[:, 0], dat[:, 1])
-            if not self.haveOcculter:
-                assert np.all(D >= 0) and np.all(D <= 1), (
-                    param_name + " must be positive and smaller than 1."
+            dat, hdr = self.get_param_data(
+                syst[param_name],
+                left_col_name=syst["csv_angsep_colname"],
+                param_name=param_name,
+                expected_ndim=expected_ndim,
+                expected_first_dim=expected_first_dim,
+            )
+            WA, D = dat[0].astype(float), dat[1].astype(float)
+
+            # check values as needed
+            if min_val is not None:
+                assert np.all(D >= min_val), (
+                    f"{param_name} in {syst['name']} may not "
+                    f"have values less than {min_val}."
                 )
+            if max_val is not None:
+                assert np.all(D <= max_val), (
+                    f"{param_name} in {syst['name']} may "
+                    f"not have values greater than {min_val}."
+                )
+
+            # check for units
+            angunit = self.get_angle_unit_from_header(hdr, syst)
+            WA = (WA * angunit).to(u.arcsec).value
+
+            # for core_area only, also need to scale the data
+            if param_name == "core_area":
+                D = (D * angunit**2).to(u.arcsec**2).value
+
+            # update IWA/OWA as needed
+            syst = self.update_syst_WAs(syst, WA, param_name)
+
             # table interpolate function
             Dinterp = scipy.interpolate.interp1d(
-                WA.astype(float),
-                D.astype(float),
-                kind="cubic",
+                WA,
+                D,
+                kind="linear",
                 fill_value=fill,
                 bounds_error=False,
             )
-            # create a callable lambda function
-            syst[param_name] = lambda l, s: np.array(
-                Dinterp((s * syst["lam"] / l).to("arcsec").value), ndmin=1
-            )
-            # IWA and OWA are constrained by the limits of the allowed WA on that table
-            syst["IWA"] = max(np.min(WA), syst.get("IWA", np.min(WA)))
-            syst["OWA"] = min(np.max(WA), syst.get("OWA", np.max(WA)))
-
+            # create a callable lambda function. for coronagraphs, we need to scale the
+            # angular separation by wavelength, but for occulters we just need to
+            # ensure that we're within the wavelength range. for core_area, we also
+            # need to scale the output by wavelengh^2.
+            if syst["occulter"]:
+                minl = syst["lam"] - syst["deltaLam"] / 2
+                maxl = syst["lam"] + syst["deltaLam"] / 2
+                if param_name == "core_area":
+                    outunit = 1 * u.arcsec**2
+                else:
+                    outunit = 1
+                syst[param_name] = (
+                    lambda lam, s, Dinterp=Dinterp, minl=minl, maxl=maxl, fill=fill: (
+                        (np.array(Dinterp(s.to("arcsec").value), ndmin=1) - fill)
+                        * np.array((minl < lam) & (lam < maxl), ndmin=1).astype(int)
+                        + fill
+                    )
+                    * outunit
+                )
+            else:
+                if param_name == "core_area":
+                    syst[param_name] = (
+                        lambda lam, s, Dinterp=Dinterp, lam0=syst["lam"]: np.array(
+                            Dinterp((s * lam0 / lam).to("arcsec").value), ndmin=1
+                        )
+                        * ((lam / lam0).decompose() * u.arcsec) ** 2
+                    )
+                else:
+                    syst[param_name] = lambda lam, s, Dinterp=Dinterp, lam0=syst[
+                        "lam"
+                    ]: np.array(Dinterp((s * lam0 / lam).to("arcsec").value), ndmin=1)
+        # now the case where we just got a scalar input
         elif isinstance(syst[param_name], numbers.Number):
-            if not self.haveOcculter:
-                assert syst[param_name] >= 0 and syst[param_name] <= 1, (
-                    param_name + " must be positive and smaller than 1."
+            # ensure paramter is within bounds
+            D = float(syst[param_name])
+            if min_val is not None:
+                assert D >= min_val, (
+                    f"{param_name} in {syst['name']} may not "
+                    f"have values less than {min_val}."
                 )
-            syst[param_name] = (
-                lambda lam, s, D=float(syst[param_name]): (
-                    (s * syst["lam"] / lam >= syst["IWA"])
-                    & (s * syst["lam"] / lam <= syst["OWA"])
+            if max_val is not None:
+                assert D <= max_val, (
+                    f"{param_name} in {syst['name']} may "
+                    f"not have values greater than {min_val}."
                 )
-                * (D - fill)
-                + fill
-            )
 
-        else:
+            # for core_area only, need to make sure that the units are right
+            if param_name == "core_area":
+                angunit = self.get_angle_unit_from_header(None, syst)
+                D = (D * angunit**2).to(u.arcsec**2).value
+
+            # ensure you have values for IWA/OWA, otherwise use defaults
+            syst = self.update_syst_WAs(syst, None, None)
+            IWA = syst["IWA"].to(u.arcsec).value
+            OWA = syst["OWA"].to(u.arcsec).value
+
+            # same as for interpolant: coronagraphs scale with wavelength, occulters
+            # don't
+            if syst["occulter"]:
+                minl = syst["lam"] - syst["deltaLam"] / 2
+                maxl = syst["lam"] + syst["deltaLam"] / 2
+                if param_name == "core_area":
+                    outunit = 1 * u.arcsec**2
+                else:
+                    outunit = 1
+
+                syst[
+                    param_name
+                ] = lambda lam, s, D=D, IWA=IWA, OWA=OWA, minl=minl, maxl=maxl, fill=fill: (  # noqa: E501
+                    (
+                        np.array(
+                            (IWA <= s.to("arcsec").value)
+                            & (s.to("arcsec").value <= OWA)
+                            & (minl < lam)
+                            & (lam < maxl),
+                            ndmin=1,
+                        ).astype(float)
+                        * (D - fill)
+                        + fill
+                    )
+                    * outunit
+                )
+            # coronagraph:
+            else:
+                if param_name == "core_area":
+                    syst[param_name] = (
+                        lambda lam, s, D=D, lam0=syst[
+                            "lam"
+                        ], IWA=IWA, OWA=OWA, fill=fill: (
+                            np.array(
+                                (IWA <= (s * lam0 / lam).to("arcsec").value)
+                                & ((s * lam0 / lam).to("arcsec").value <= OWA),
+                                ndmin=1,
+                            ).astype(float)
+                            * (lam / lam0 * u.arcsec) ** 2
+                        )
+                        * (D - fill)
+                        + fill
+                    )
+                else:
+                    syst[param_name] = (
+                        lambda lam, s, D=D, lam0=syst[
+                            "lam"
+                        ], IWA=IWA, OWA=OWA, fill=fill: (
+                            np.array(
+                                (IWA <= (s * lam0 / lam).to("arcsec").value)
+                                & ((s * lam0 / lam).to("arcsec").value <= OWA),
+                                ndmin=1,
+                            ).astype(float)
+                        )
+                        * (D - fill)
+                        + fill
+                    )
+        # finally the case where the input is None
+        elif syst[param_name] is None:
             syst[param_name] = None
+        # anything else (not string, number, or None) throws an error
+        else:
+            raise TypeError(
+                f"{param_name} for system {syst['name']} is neither a "
+                f"string nor a number. I don't know what to do with that."
+            )
 
         return syst
 
-    def get_param_data(self, pth, left_col_name=None, param_name=None):
+    def get_param_data(
+        self,
+        ipth,
+        left_col_name=None,
+        param_name=None,
+        expected_ndim=None,
+        expected_first_dim=None,
+    ):
         """Gets the data from a file, used primarily to create interpolants for
         coronagraph parameters
 
         Args:
-            pth (str):
+            ipth (str):
                 String to file location, will also work with any other path object
-            left_col_name (str):
-                String that represents the left column, as in the one that will be
-                inputted when getting a value from the interpolant
-            param_name (str):
-                String that is the header for the parameter of interest
+            left_col_name (str,optional):
+                For CSV files only. String representing the column containing the
+                independent parameter to be extracted. This is for use in the case
+                where the CSV file contains multiple columns and only two need to be
+                returned. Defaults None.
+            param_name (str, optional):
+                For CSV files only. String representing the column containing the
+                dependent parameter to be extracted. This is for use in the case where
+                the CSV file contains multiple columns and only two need to be returned.
+                Defaults None.
+            expected_ndim (int, optional):
+                Expected number of dimensions.  Only checked if not None. Defaults None.
+            expected_first_dim (int, optional):
+                Expected size of first dimension of data.  Only checked if not None.
+                Defaults None
 
         Returns:
-            dat (~numpy.ndarray):
-                Two column array with the data for the parameter
+            tuple:
+                dat (~numpy.ndarray):
+                    Data array
+                hdr (list or astropy.io.fits.header.Header):
+                    Data header.  For CVS files this is a list of column header strings.
+
+        .. note::
+
+            CSV files *must* have a single header row
 
         """
+        # Check that path represents a valid file
+        pth = os.path.normpath(os.path.expandvars(ipth))
+        assert os.path.isfile(pth), f"{ipth} is not a valid file."
+
         # Check for fits or csv file
         ext = pth.split(".")[-1]
-        assert ext == "fits" or ext == "csv", "%s must be a fits or csv file." % pth
-        if ext == "fits":
+        assert ext.lower() in ["fits", "csv"], f"{pth} must be a fits or csv file."
+        if ext.lower() == "fits":
             with fits.open(pth) as f:
-                dat = f[0].data
+                dat = f[0].data.squeeze()
+                hdr = f[0].header
         else:
             # Need to get all of the headers and data, then associate them in the same
             # ndarray that the fits files would generate
             table_vals = np.genfromtxt(pth, delimiter=",", skip_header=1)
-            if table_vals.shape[1] == 2:
-                # if there are only two columns retun the data without looking at the
-                # headers
-                dat = table_vals
-            else:
-                table_headers = np.genfromtxt(
-                    pth, delimiter=",", skip_footer=len(table_vals), dtype=str
-                )
-                left_column_location = np.where(table_headers == left_col_name)[0][0]
-                param_location = np.where(table_headers == param_name)[0][0]
+            hdr = np.genfromtxt(
+                pth, delimiter=",", skip_footer=len(table_vals), dtype=str
+            )
+
+            if left_col_name is not None:
+                assert (
+                    param_name is not None
+                ), "If left_col_name is nont None, param_name cannot be None."
+
+                assert (
+                    left_col_name in hdr
+                ), f"{left_col_name} not found in table header for file {ipth}"
+                assert (
+                    param_name in hdr
+                ), f"{param_name} not found in table header for file {ipth}"
+
+                left_column_location = np.where(hdr == left_col_name)[0][0]
+                param_location = np.where(hdr == param_name)[0][0]
                 dat = np.vstack(
                     [table_vals[:, left_column_location], table_vals[:, param_location]]
                 ).T
-            assert len(dat.shape) == 2 and 2 in dat.shape, (
-                param_name + " wrong data shape."
+                hdr = [left_col_name, param_name]
+            else:
+                dat = table_vals
+
+        if expected_ndim is not None:
+            assert len(dat.shape) == expected_ndim, (
+                f"Data shape did not match expected {expected_ndim} "
+                f"dimensions for file {ipth}"
+            )
+
+        if expected_first_dim is not None:
+            assert expected_first_dim in dat.shape, (
+                f"Expected first dimension size {expected_first_dim} not found in any "
+                f"data dimension for file {ipth}."
             )
-        return dat
+
+            if dat.shape[0] != expected_first_dim:
+                assert len(dat.shape) == 2, (
+                    f"Data in file {ipth} contains a dimension of expected size "
+                    f"{expected_first_dim}, but it is not the first dimension, and the "
+                    "data has dimensionality of > 2, so I do not know how to reshape "
+                    "it."
+                )
+
+                dat = dat.transpose()
+
+        return dat, hdr
 
     def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, returnExtra=False, TK=None):
         """Calculates electron count rates for planet signal, background noise,
@@ -973,106 +1782,146 @@ def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, returnExtra=False, TK=No
 
         """
 
-        # get scienceInstrument and starlightSuppressionSystem
+        # grab all count rates
+        C_star, C_p, C_sr, C_z, C_ez, C_dc, C_bl, Npix = self.Cp_Cb_Csp_helper(
+            TL, sInds, fZ, fEZ, dMag, WA, mode
+        )
+
+        # readout noise
         inst = mode["inst"]
-        syst = mode["syst"]
+        C_rn = Npix * inst["sread"] / inst["texp"]
+
+        # background signal rate
+        C_b = C_sr + C_z + C_ez + C_bl + C_dc + C_rn
+
+        # for characterization, Cb must include the planet
+        # C_sp = spatial structure to the speckle including post-processing contrast
+        # factor and stability factor
+        if not (mode["detectionMode"]):
+            C_b = C_b + C_p
+            C_sp = C_sr * TL.PostProcessing.ppFact_char(WA) * self.stabilityFact
+        else:
+            C_sp = C_sr * TL.PostProcessing.ppFact(WA) * self.stabilityFact
 
-        # get mode wavelength
+        if returnExtra:
+            # organize components into an optional fourth result
+            C_extra = dict(
+                C_sr=C_sr.to("1/s"),
+                C_z=C_z.to("1/s"),
+                C_ez=C_ez.to("1/s"),
+                C_dc=C_dc.to("1/s"),
+                C_rn=C_rn.to("1/s"),
+                C_star=C_star.to("1/s"),
+                C_bl=C_bl.to("1/s"),
+            )
+            return C_p.to("1/s"), C_b.to("1/s"), C_sp.to("1/s"), C_extra
+        else:
+            return C_p.to("1/s"), C_b.to("1/s"), C_sp.to("1/s")
+
+    def Cp_Cb_Csp_helper(self, TL, sInds, fZ, fEZ, dMag, WA, mode):
+        """Helper method for Cp_Cb_Csp that performs lots of common computations
+        Args:
+            TL (:ref:`TargetList`):
+                TargetList class object
+            sInds (~numpy.ndarray(int)):
+                Integer indices of the stars of interest
+            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
+                Surface brightness of local zodiacal light in units of 1/arcsec2
+            fEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
+                Surface brightness of exo-zodiacal light in units of 1/arcsec2
+            dMag (~numpy.ndarray(float)):
+                Differences in magnitude between planets and their host star
+            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
+                Working angles of the planets of interest in units of arcsec
+            mode (dict):
+                Selected observing mode
+
+        Returns:
+            tuple:
+                C_star (~astropy.units.Quantity(~numpy.ndarray(float))):
+                    Non-coronagraphic star count rate (1/s)
+                C_p0 (~astropy.units.Quantity(~numpy.ndarray(float))):
+                    Planet count rate (1/s)
+                C_sr (~astropy.units.Quantity(~numpy.ndarray(float))):
+                    Starlight residual count rate (1/s)
+                C_z (~astropy.units.Quantity(~numpy.ndarray(float))):
+                    Local zodi count rate (1/s)
+                C_ez (~astropy.units.Quantity(~numpy.ndarray(float))):
+                    Exozodi count rate (1/s)
+                C_dc (~astropy.units.Quantity(~numpy.ndarray(float))):
+                    Dark current count rate (1/s)
+                C_bl (~astropy.units.Quantity(~numpy.ndarray(float))):
+                    Background leak count rate (1/s)'
+                Npix (float):
+                    Number of pixels in photometric aperture
+        """
+
+        # get scienceInstrument and starlightSuppressionSystem and wavelength
+        inst = mode["inst"]
+        syst = mode["syst"]
         lam = mode["lam"]
-        # get mode fractional bandwidth
-        BW = mode["BW"]  # noqa: F841
-        # get mode bandwidth (including any IFS spectral resolving power)
-        deltaLam = (
-            lam / inst["Rs"] if "spec" in inst["name"].lower() else mode["deltaLam"]
-        )
 
         # coronagraph parameters
         occ_trans = syst["occ_trans"](lam, WA)
         core_thruput = syst["core_thruput"](lam, WA)
-        core_contrast = syst["core_contrast"](lam, WA)
-        core_area = syst["core_area"](lam, WA)
-
-        # solid angle of photometric aperture, specified by core_area (optional)
-        Omega = core_area * u.arcsec**2.0
-        # if zero, get omega from (lambda/D)^2
-        Omega[Omega == 0] = (
-            np.pi * (np.sqrt(2.0) / 2.0 * lam / self.pupilDiam * u.rad) ** 2.0
-        )
+        Omega = syst["core_area"](lam, WA)
+
         # number of pixels per lenslet
         pixPerLens = inst["lenslSamp"] ** 2.0
-        # number of pixels in the photometric aperture = Omega / theta^2
+
+        # number of detector pixels in the photometric aperture = Omega / theta^2
         Npix = pixPerLens * (Omega / inst["pixelScale"] ** 2.0).decompose().value
 
         # get stellar residual intensity in the planet PSF core
-        # OPTION 1: if core_mean_intensity is missing, use the core_contrast
+        # if core_mean_intensity is None, fall back to using core_contrast
         if syst["core_mean_intensity"] is None:
+            core_contrast = syst["core_contrast"](lam, WA)
             core_intensity = core_contrast * core_thruput
-        # OPTION 2A: otherwise use core_mean_intensity and adjust for contrast_floor
-        elif syst["contrast_floor"] is not None:
-            core_mean_intensity = syst["core_mean_intensity"](lam, WA)
-            # if a platescale was specified with the coro parameters, apply correction
-            if syst["core_platescale"] is not None:
-                core_mean_intensity *= (
-                    (
-                        inst["pixelScale"]
-                        / syst["core_platescale"]
-                        / (lam / self.pupilDiam)
-                    )
-                    .decompose()
-                    .value
-                )
-            contrast = core_mean_intensity * Npix / core_thruput
-            contrast_floor = syst["contrast_floor"]
-            core_intensity = core_mean_intensity * Npix
-            core_intensity[np.where(contrast < contrast_floor)] = (
-                contrast_floor * core_thruput[np.where(contrast < contrast_floor)]
-            )
-        # OPTION 2B: otherwise use core_mean_intensity
         else:
-            core_mean_intensity = syst["core_mean_intensity"](lam, WA)
-            # if a platesale was specified with the coro parameters, apply correction
-            if syst["core_platescale"] is not None:
-                core_mean_intensity *= (
-                    (
-                        inst["pixelScale"]
-                        / syst["core_platescale"]
-                        / (lam / self.pupilDiam)
-                    )
-                    .decompose()
-                    .value
+            # if we're here, we're using the core mean intensity
+            core_mean_intensity = syst["core_mean_intensity"](
+                lam, WA, TL.diameter[sInds]
+            )
+            # also, if we're here, we must have a platescale defined
+            core_platescale = syst["core_platescale"]
+            # furthermore, if we're a coronagraph, we have to scale by wavelength
+            if not (syst["occulter"]) and (syst["lam"] != mode["lam"]):
+                core_platescale *= mode["lam"] / syst["lam"]
+
+            # core_intensity is the mean intensity times the number of map pixels
+            core_intensity = core_mean_intensity * Omega / core_platescale**2
+
+            # finally, if a contrast floor was set, make sure we're not violating it
+            if syst["contrast_floor"] is not None:
+                below_contrast_floor = (
+                    core_intensity / core_thruput < syst["contrast_floor"]
+                )
+                core_intensity[below_contrast_floor] = (
+                    syst["contrast_floor"] * core_thruput[below_contrast_floor]
                 )
-            core_intensity = core_mean_intensity * Npix
 
         # cast sInds to array
         sInds = np.array(sInds, ndmin=1, copy=False)
-        # get star magnitude
-        mV = TL.starMag(sInds, lam)
 
-        # ELECTRON COUNT RATES [ s^-1 ]
-        # spectral flux density = F0 * A * Dlam * QE * T (attenuation due to optics)
-        attenuation = inst["optics"] * syst["optics"]
-        F_0 = TL.starF0(sInds, mode)
+        # Star fluxes (ph/m^2/s)
+        flux_star = TL.starFlux(sInds, mode)
 
-        C_F0 = F_0 * self.pupilArea * deltaLam * inst["QE"](lam) * attenuation
-        # planet conversion rate (planet shot)
-        C_p0 = C_F0 * 10.0 ** (-0.4 * (mV + dMag)) * core_thruput
+        # ELECTRON COUNT RATES [ s^-1 ]
+        # non-coronagraphic star counts
+        C_star = flux_star * mode["losses"]
+        # planet counts:
+        C_p0 = (C_star * 10.0 ** (-0.4 * dMag) * core_thruput).to("1/s")
         # starlight residual
-        C_sr = C_F0 * 10.0 ** (-0.4 * mV) * core_intensity
+        C_sr = (C_star * core_intensity).to("1/s")
         # zodiacal light
-        C_z = C_F0 * fZ * Omega * occ_trans
+        C_z = (mode["F0"] * mode["losses"] * fZ * Omega * occ_trans).to("1/s")
         # exozodiacal light
-        C_ez = C_F0 * fEZ * Omega * core_thruput
+        if self.use_core_thruput_for_ez:
+            C_ez = (mode["F0"] * mode["losses"] * fEZ * Omega * core_thruput).to("1/s")
+        else:
+            C_ez = (mode["F0"] * mode["losses"] * fEZ * Omega * occ_trans).to("1/s")
         # dark current
         C_dc = Npix * inst["idark"]
-        # exposure time
-        if self.texp_flag:
-            texp = 1 / C_p0 / 10  # Use 1/C_p0 as frame time for photon counting
-        else:
-            texp = inst["texp"]
-        # clock-induced-charge
-        C_cc = Npix * inst["CIC"] / texp
-        # readout noise
-        C_rn = Npix * inst["sread"] / texp
 
         # only calculate binary leak if you have a model and relevant data
         # in the targelist
@@ -1107,70 +1956,16 @@ def Cp_Cb_Csp(self, TL, sInds, fZ, fEZ, dMag, WA, mode, returnExtra=False, TK=No
             bleaks = brawleaks * 10 ** (-0.4 * bdms)
             bleaks[np.isnan(bleaks)] = 0
 
-            C_bl = (cleaks + bleaks) * C_F0 * 10.0 ** (-0.4 * mV) * core_thruput
+            C_bl = (cleaks + bleaks) * C_star * core_thruput
         else:
             C_bl = np.zeros(len(sInds)) / u.s
 
-        # C_p = PLANET SIGNAL RATE
-        # photon counting efficiency
-        PCeff = inst["PCeff"]
-        # radiation dosage
-        radDos = mode["radDos"]
-        # photon-converted 1 frame (minimum 1 photon)
-        phConv = np.clip(
-            ((C_p0 + C_sr + C_z + C_ez) / Npix * texp).decompose().value, 1, None
-        )
-        # net charge transfer efficiency
-        NCTE = 1.0 + (radDos / 4.0) * 0.51296 * (np.log10(phConv) + 0.0147233)
-        # planet signal rate
-        C_p = C_p0 * PCeff * NCTE
-
-        # C_b = NOISE VARIANCE RATE
-        # corrections for Ref star Differential Imaging e.g. dMag=3 and 20% time on ref
-        # k_SZ for speckle and zodi light, and k_det for detector
-        k_SZ = (
-            1.0 + 1.0 / (10 ** (0.4 * self.ref_dMag) * self.ref_Time)
-            if self.ref_Time > 0
-            else 1.0
-        )
-        k_det = 1.0 + self.ref_Time
-        # calculate Cb
-        ENF2 = inst["ENF"] ** 2
-        C_b = k_SZ * ENF2 * (C_sr + C_z + C_ez + C_bl) + k_det * (
-            ENF2 * (C_dc + C_cc) + C_rn
-        )
-        # for characterization, Cb must include the planet
-        if not (mode["detectionMode"]):
-            C_b = C_b + ENF2 * C_p0
-            C_sp = C_sr * TL.PostProcessing.ppFact_char(WA) * self.stabilityFact
-        else:
-            # C_sp = spatial structure to the speckle including post-processing
-            #        contrast factor and stability factor
-            C_sp = C_sr * TL.PostProcessing.ppFact(WA) * self.stabilityFact
-
-        if returnExtra:
-            # organize components into an optional fourth result
-            C_extra = dict(
-                C_sr=C_sr.to("1/s"),
-                C_z=C_z.to("1/s"),
-                C_ez=C_ez.to("1/s"),
-                C_dc=C_dc.to("1/s"),
-                C_cc=C_cc.to("1/s"),
-                C_rn=C_rn.to("1/s"),
-                C_F0=C_F0.to("1/s"),
-                C_p0=C_p0.to("1/s"),
-                C_bl=C_bl.to("1/s"),
-            )
-            return C_p.to("1/s"), C_b.to("1/s"), C_sp.to("1/s"), C_extra
-        else:
-            return C_p.to("1/s"), C_b.to("1/s"), C_sp.to("1/s")
+        return C_star, C_p0, C_sr, C_z, C_ez, C_dc, C_bl, Npix
 
     def calc_intTime(self, TL, sInds, fZ, fEZ, dMag, WA, mode, TK=None):
-        """Finds integration time for a specific target system
+        """Finds integration time to reach a given dMag at a particular WA with given
+        local and exozodi values for specific targets and for a specific observing mode.
 
-        This method is called in the run_sim() method of the SurveySimulation
-        class object. It defines the data type expected, integration time is
-        determined by specific OpticalSystem classes.
 
         Args:
             TL (:ref:`TargetList`):
@@ -1193,14 +1988,26 @@ class object. It defines the data type expected, integration time is
 
         Returns:
             ~astropy.units.Quantity(~numpy.ndarray(float)):
-                Integration times in units of days
+                Integration times
+
+        .. note::
+
+            All infeasible integration times are returned as NaN values
 
         """
+        # count rates
+        C_p, C_b, C_sp = self.Cp_Cb_Csp(TL, sInds, fZ, fEZ, dMag, WA, mode, TK=TK)
 
-        # cast sInds to array
-        sInds = np.array(sInds, ndmin=1, copy=False)
-        # default intTimes are 1 day
-        intTime = np.ones(len(sInds)) * u.day
+        # get SNR threshold
+        SNR = mode["SNR"]
+
+        with np.errstate(divide="ignore", invalid="ignore"):
+            intTime = np.true_divide(
+                SNR**2.0 * C_b, (C_p**2.0 - (SNR * C_sp) ** 2.0)
+            ).to("day")
+
+        # infinite and negative values are set to NAN
+        intTime[np.isinf(intTime) | (intTime.value < 0.0)] = np.nan
 
         return intTime
 
@@ -1238,11 +2045,31 @@ def calc_dMag_per_intTime(
             numpy.ndarray(float):
                 Achievable dMag for given integration time and working angle
 
+        .. warning::
+
+            The prototype implementation assumes the exact same integration time model
+            as the other prototype methods (specifically Cp_Cb_Csp and calc_intTime).
+            If either of these is overloaded, and, in particular, if C_b and/or C_sp are
+            not modeled as independent of C_p, then the analytical approach used here
+            will *not* work and must be replaced with numerical inversion.
+
         """
 
-        dMag = np.ones((len(sInds),))
+        # cast sInds to array
+        sInds = np.array(sInds, ndmin=1, copy=False)
+
+        if (C_b is None) or (C_sp is None):
+            _, C_b, C_sp = self.Cp_Cb_Csp(
+                TL, sInds, fZ, fEZ, np.zeros(len(sInds)), WA, mode, TK=TK
+            )
+
+        C_p = mode["SNR"] * np.sqrt(C_sp**2 + C_b / intTimes)  # planet count rate
+        core_thruput = mode["syst"]["core_thruput"](mode["lam"], WA)
+        flux_star = TL.starFlux(sInds, mode)
 
-        return dMag
+        dMag = -2.5 * np.log10(C_p / (flux_star * mode["losses"] * core_thruput))
+
+        return dMag.value
 
     def ddMag_dt(
         self, intTimes, TL, sInds, fZ, fEZ, WA, mode, C_b=None, C_sp=None, TK=None
@@ -1279,49 +2106,54 @@ def ddMag_dt(
                 in units of 1/s
 
         """
+        # cast sInds to array
+        sInds = np.array(sInds, ndmin=1, copy=False)
 
-        ddMagdt = np.zeros((len(sInds),)) / u.s
+        if (C_b is None) or (C_sp is None):
+            _, C_b, C_sp = self.Cp_Cb_Csp(
+                TL, sInds, fZ, fEZ, np.zeros(len(sInds)), WA, mode, TK=TK
+            )
+
+        ddMagdt = 5 / 4 / np.log(10) * C_b / (C_b * intTimes + (C_sp * intTimes) ** 2)
 
-        return ddMagdt
+        return ddMagdt.to("1/s")
 
-    def int_time_denom_obj(self, dMag, *args):
+    def calc_saturation_dMag(self, TL, sInds, fZ, fEZ, WA, mode, TK=None):
         """
-        Objective function for calc_dMag_per_intTime's calculation of the root
-        of the denominator of calc_inTime to determine the upper bound to use
-        for minimizing to find the correct dMag
+        This calculates the delta magnitude for each target star that
+        corresponds to an infinite integration time.
 
         Args:
-            dMag (~numpy.ndarray(float)):
-                dMag being tested
-            *args:
-                all the other arguments that calc_intTime needs
+            TL (:ref:`TargetList`):
+                TargetList class object
+            sInds (numpy.ndarray(int)):
+                Integer indices of the stars of interest
+            fZ (~astropy.units.Quantity(~numpy.ndarray(float))):
+                Surface brightness of local zodiacal light in units of 1/arcsec2
+            fEZ (~astropy.units.Quantity(~numpy.ndarray(float))):
+                Surface brightness of exo-zodiacal light in units of 1/arcsec2
+            WA (~astropy.units.Quantity(~numpy.ndarray(float))):
+                Working angles of the planets of interest in units of arcsec
+            mode (dict):
+                Selected observing mode
+            TK (:ref:`TimeKeeping`, optional):
+                Optional TimeKeeping object (default None), used to model detector
+                degradation effects where applicable.
 
         Returns:
-            ~astropy.units.Quantity(~numpy.ndarray(float)):
-                Denominator of integration time expression
+            ~numpy.ndarray(float):
+                Saturation (maximum achievable) dMag for each target star
         """
-        TL, sInds, fZ, fEZ, WA, mode, TK = args
-        C_p, C_b, C_sp = self.Cp_Cb_Csp(TL, sInds, fZ, fEZ, dMag, WA, mode, TK=TK)
-        denom = C_p.value**2 - (mode["SNR"] * C_sp.value) ** 2
-        return denom
 
-    def dMag_per_intTime_obj(self, dMag, *args):
-        """
-        Objective function for calc_dMag_per_intTime's minimize_scalar function
-        that uses calc_intTime from Nemati and then compares the value to the
-        true intTime value
+        _, C_b, C_sp = self.Cp_Cb_Csp(
+            TL, sInds, fZ, fEZ, np.zeros(len(sInds)), WA, mode, TK=TK
+        )
 
-        Args:
-            dMag (~numpy.ndarray(float)):
-                dMag being tested
-            *args:
-                all the other arguments that calc_intTime needs
+        flux_star = TL.starFlux(sInds, mode)
+        core_thruput = mode["syst"]["core_thruput"](mode["lam"], WA)
 
-        Returns:
-            ~numpy.ndarray(float):
-                Absolute difference between true and evaluated integration time in days.
-        """
-        TL, sInds, fZ, fEZ, WA, mode, TK, true_intTime = args
-        est_intTime = self.calc_intTime(TL, sInds, fZ, fEZ, dMag, WA, mode, TK)
-        abs_diff = np.abs(true_intTime.to("day").value - est_intTime.to("day").value)
-        return abs_diff
+        dMagmax = -2.5 * np.log10(
+            mode["SNR"] * C_sp / (flux_star * mode["losses"] * core_thruput)
+        )
+
+        return dMagmax.value
diff --git a/EXOSIMS/Prototypes/StarCatalog.py b/EXOSIMS/Prototypes/StarCatalog.py
index b901bdbb3..363e88ac4 100644
--- a/EXOSIMS/Prototypes/StarCatalog.py
+++ b/EXOSIMS/Prototypes/StarCatalog.py
@@ -15,6 +15,9 @@ class StarCatalog(object):
         cachedir (str, optional):
             Full path to cachedir.
             If None (default) use default (see :ref:`EXOSIMSCACHE`)
+        VmagFill (float):
+            Fill value for V magnitudes. Defaults to 0.1. Must be set to non-zero value
+            or TargetList will fail to build.
         **specs:
             :ref:`sec:inputspec`
 
@@ -76,7 +79,7 @@ class StarCatalog(object):
 
     _modtype = "StarCatalog"
 
-    def __init__(self, ntargs=1, cachedir=None, **specs):
+    def __init__(self, ntargs=1, cachedir=None, VmagFill=0.1, **specs):
 
         # start the outspec
         self._outspec = {}
@@ -109,7 +112,7 @@ def __init__(self, ntargs=1, cachedir=None, **specs):
         self.Spec = np.array(["G"] * ntargs)  # spectral types
         self.Umag = np.zeros(ntargs)  # U magnitude
         self.Bmag = np.zeros(ntargs)  # B magnitude
-        self.Vmag = np.zeros(ntargs)  # V magnitude
+        self.Vmag = np.zeros(ntargs) + VmagFill  # V magnitude
         self.Rmag = np.zeros(ntargs)  # R magnitude
         self.Imag = np.zeros(ntargs)  # I magnitude
         self.Jmag = np.zeros(ntargs)  # J magnitude
@@ -123,6 +126,7 @@ def __init__(self, ntargs=1, cachedir=None, **specs):
 
         # populate outspecs
         self._outspec["ntargs"] = self.ntargs
+        self._outspec["VmagFill"] = VmagFill
 
     def __str__(self):
         """String representation of the StarCatalog object
diff --git a/EXOSIMS/Prototypes/SurveySimulation.py b/EXOSIMS/Prototypes/SurveySimulation.py
index 9facb6318..ec2889bfa 100644
--- a/EXOSIMS/Prototypes/SurveySimulation.py
+++ b/EXOSIMS/Prototypes/SurveySimulation.py
@@ -369,63 +369,6 @@ def __init__(
         SU = self.SimulatedUniverse
         TK = self.TimeKeeping
 
-        # work out limiting dMag for all observing modes
-        for mode in OS.observingModes:
-            # This will not work right without being rewritten. When orbit
-            # scaling is on the int_WA value is mode dependent, so there are
-            # cases where this is outside of the mode's WA range
-            core_contrast = mode["syst"]["core_contrast"](
-                mode["syst"]["lam"], TL.int_WA[0]
-            )
-
-            if core_contrast == 1 and mode["syst"]["core_mean_intensity"] is not None:
-                core_thruput = mode["syst"]["core_thruput"](mode["lam"], TL.int_WA[0])
-                core_mean_intensity = mode["syst"]["core_mean_intensity"](
-                    mode["lam"], TL.int_WA[0]
-                )
-                core_area = mode["syst"]["core_area"](mode["lam"], TL.int_WA[0])
-                # solid angle of photometric aperture, specified by core_area (optional)
-                Omega = core_area * u.arcsec**2
-                # if zero, get omega from (lambda/D)^2
-                Omega[Omega == 0] = (
-                    np.pi * (np.sqrt(2) / 2 * mode["lam"] / OS.pupilDiam * u.rad) ** 2
-                )
-                # number of pixels per lenslet
-                pixPerLens = mode["inst"]["lenslSamp"] ** 2
-                # number of pixels in the photometric aperture = Omega / theta^2
-                Npix = (
-                    pixPerLens
-                    * (Omega / mode["inst"]["pixelScale"] ** 2).decompose().value
-                )
-
-                if mode["syst"]["core_platescale"] is not None:
-                    core_mean_intensity *= (
-                        (
-                            mode["inst"]["pixelScale"]
-                            / mode["syst"]["core_platescale"]
-                            / (mode["lam"] / OS.pupilDiam)
-                        )
-                        .decompose()
-                        .value
-                    )
-                core_intensity = core_mean_intensity * Npix
-
-                core_contrast = core_intensity / core_thruput
-
-            SNR = mode["SNR"]
-            contrast_stability = OS.stabilityFact * core_contrast
-            if not (mode["detectionMode"]):
-                Fpp = TL.PostProcessing.ppFact_char(TL.int_WA[0])
-            else:
-                Fpp = TL.PostProcessing.ppFact(TL.int_WA[0])
-            PCEff = mode["inst"]["PCeff"]
-            dMaglimit = -2.5 * np.log10(Fpp * contrast_stability * SNR / PCEff)
-            self.vprint(
-                "Limiting delta magnitude for mode syst: {} inst: {} is {}".format(
-                    mode["systName"], mode["instName"], dMaglimit
-                )
-            )
-
         # initialize arrays updated in run_sim()
         self.initializeStorageArrays()
 
@@ -473,6 +416,7 @@ def __init__(
                 self.fZmins[n] = np.array([])
                 self.fZtypes[n] = np.array([])
 
+        sInds = np.arange(TL.nStars)  # Initialize some sInds array
         for mode in allModes:
             # This instantiates fZMap arrays for every starlight suppresion system
             modeHashName = self.cachefname[0:-2] + "_" + mode["syst"]["name"] + "."
@@ -480,24 +424,27 @@ def __init__(
                 self.Observatory, TL, self.TimeKeeping, mode, modeHashName, self.koTimes
             )
 
+            # TODO: do we need to do this for all modes? doing det only breaks other
+            # schedulers, but maybe there's a better approach here.
+
+            (
+                self.fZmins[mode["syst"]["name"]],
+                self.fZtypes[mode["syst"]["name"]],
+            ) = self.ZodiacalLight.calcfZmin(
+                sInds,
+                self.Observatory,
+                TL,
+                self.TimeKeeping,
+                mode,
+                modeHashName,
+                self.koMaps[mode["syst"]["name"]],
+                self.koTimes,
+            )
+
         # Precalculating intTimeFilter for coronagraph
-        sInds = np.arange(TL.nStars)  # Initialize some sInds array
-        koMap = self.koMaps[mode["syst"]["name"]]
-        (
-            self.fZmins[mode["syst"]["name"]],
-            self.fZtypes[mode["syst"]["name"]],
-        ) = self.ZodiacalLight.calcfZmin(
-            sInds,
-            self.Observatory,
-            TL,
-            self.TimeKeeping,
-            mode,
-            modeHashName,
-            koMap,
-            self.koTimes,
-        )  # find fZmin to use in intTimeFilter
+        # find fZmin to use in intTimeFilter
         self.valfZmin, self.absTimefZmin = self.ZodiacalLight.extractfZmin(
-            self.fZmins[mode["syst"]["name"]], sInds, self.koTimes
+            self.fZmins[det_mode["syst"]["name"]], sInds, self.koTimes
         )
         fEZ = self.ZodiacalLight.fEZ0  # grabbing fEZ0
         dMag = TL.int_dMag[sInds]  # grabbing dMag
@@ -1796,23 +1743,20 @@ def observation_detection(self, sInd, intTime, mode):
         extraTime = intTime * (mode["timeMultiplier"] - 1.0)  # calculates extraTime
         success = TK.allocate_time(
             intTime + extraTime + Obs.settlingTime + mode["syst"]["ohTime"], True
-        )  # allocates time
+        )
         assert success, "Could not allocate observation detection time ({}).".format(
             intTime + extraTime + Obs.settlingTime + mode["syst"]["ohTime"]
         )
-        dt = intTime / float(
-            self.ntFlux
-        )  # calculates partial time to be added for every ntFlux
-
+        # calculates partial time to be added for every ntFlux
+        dt = intTime / float(self.ntFlux)
         # find indices of planets around the target
         pInds = np.where(SU.plan2star == sInd)[0]
 
         # initialize outputs
         detected = np.array([], dtype=int)
         fZ = 0.0 / u.arcsec**2
-        systemParams = SU.dump_system_params(
-            sInd
-        )  # write current system params by default
+        # write current system params by default
+        systemParams = SU.dump_system_params(sInd)
         SNR = np.zeros(len(pInds))
 
         # if any planet, calculate SNR
@@ -1822,10 +1766,9 @@ def observation_detection(self, sInd, intTime, mode):
             systemParamss = np.empty(self.ntFlux, dtype="object")
             Ss = np.zeros((self.ntFlux, len(pInds)))
             Ns = np.zeros((self.ntFlux, len(pInds)))
+            # accounts for the time since the current time
+            timePlus = Obs.settlingTime.copy() + mode["syst"]["ohTime"].copy()
             # integrate the signal (planet flux) and noise
-            timePlus = (
-                Obs.settlingTime.copy() + mode["syst"]["ohTime"].copy()
-            )  # accounts for the time since the current time
             for i in range(self.ntFlux):
                 # allocate first half of dt
                 timePlus += dt / 2.0
@@ -2049,9 +1992,8 @@ def observation_characterization(self, sInd, mode):
         # to characterize
         characterized = np.zeros(len(det), dtype=int)
         fZ = 0.0 / u.arcsec**2.0
-        systemParams = SU.dump_system_params(
-            sInd
-        )  # write current system params by default
+        # write current system params by default
+        systemParams = SU.dump_system_params(sInd)
         SNR = np.zeros(len(det))
         intTime = None
         if len(det) == 0:  # nothing to characterize
@@ -2296,8 +2238,12 @@ def calc_signal_noise(
         TK = self.TimeKeeping
 
         # calculate optional parameters if not provided
-        fZ = fZ if fZ else ZL.fZ(Obs, TL, sInd, TK.currentTimeAbs.copy(), mode)
-        fEZ = fEZ if fEZ else SU.fEZ[pInds]
+        fZ = (
+            fZ
+            if (fZ is not None)
+            else ZL.fZ(Obs, TL, sInd, TK.currentTimeAbs.copy(), mode)
+        )
+        fEZ = fEZ if (fEZ is not None) else SU.fEZ[pInds]
 
         # if lucky_planets, use lucky planet params for dMag and WA
         if SU.lucky_planets and mode in list(
@@ -2309,8 +2255,8 @@ def calc_signal_noise(
                 pInds
             ]  # working angle
         else:
-            dMag = dMag if dMag else SU.dMag[pInds]
-            WA = WA if WA else SU.WA[pInds]
+            dMag = dMag if (dMag is not None) else SU.dMag[pInds]
+            WA = WA if (WA is not None) else SU.WA[pInds]
 
         # initialize Signal and Noise arrays
         Signal = np.zeros(len(pInds))
@@ -2712,14 +2658,13 @@ def find_known_plans(self):
         Mj = 317.8 * u.earthMass
         Mpj = SU.Mp / Mj  # planet masses in jupiter mass units
         Ms = TL.MsTrue[SU.plan2star]
-        Teff = TL.stellarTeff(SU.plan2star)
+        Teff = TL.Teff[SU.plan2star]
         mu = const.G * (SU.Mp + Ms)
         T = (2.0 * np.pi * np.sqrt(SU.a**3 / mu)).to(u.yr)
         e = SU.e
 
-        t_filt = np.where((Teff.value > 3000) & (Teff.value < 6800))[
-            0
-        ]  # pinds in correct temp range
+        # pinds in correct temp range
+        t_filt = np.where((Teff.value > 3000) & (Teff.value < 6800))[0]
 
         K = (
             (c / np.sqrt(1 - e[t_filt]))
@@ -2730,9 +2675,8 @@ def find_known_plans(self):
         )
 
         K_filter = (T[t_filt].to(u.d) / 10**4).value  # create period-filter
-        K_filter[
-            np.where(K_filter < 0.03)[0]
-        ] = 0.03  # if period-filter value is lower than .03, set to .03
+        # if period-filter value is lower than .03, set to .03
+        K_filter[np.where(K_filter < 0.03)[0]] = 0.03
         k_filt = t_filt[np.where(K.value > K_filter)[0]]  # pinds in the correct K range
 
         if PPop.scaleOrbits:
@@ -2742,21 +2686,20 @@ def find_known_plans(self):
 
         Rp_plan_lo = 0.80 / np.sqrt(a_plan)
 
-        a_filt = k_filt[
-            np.where((a_plan[k_filt] > 0.95) & (a_plan[k_filt] < 1.67))[0]
-        ]  # pinds in habitable zone
+        # pinds in habitable zone
+        a_filt = k_filt[np.where((a_plan[k_filt] > 0.95) & (a_plan[k_filt] < 1.67))[0]]
+        # rocky planets
         r_filt = a_filt[
             np.where(
                 (SU.Rp.value[a_filt] >= Rp_plan_lo[a_filt])
                 & (SU.Rp.value[a_filt] < 1.4)
             )[0]
-        ]  # rocky planets
+        ]
         self.known_earths = np.union1d(self.known_earths, r_filt).astype(int)
 
+        # these are known_rv stars with earths around them
         known_stars = np.unique(SU.plan2star[k_filt])  # these are known_rv stars
-        known_rocky = np.unique(
-            SU.plan2star[r_filt]
-        )  # these are known_rv stars with earths around them
+        known_rocky = np.unique(SU.plan2star[r_filt])
 
         # if include_known_RV, then filter out all other sInds
         if self.include_known_RV is not None:
diff --git a/EXOSIMS/Prototypes/TargetList.py b/EXOSIMS/Prototypes/TargetList.py
index 3930c9f69..afdbc1384 100644
--- a/EXOSIMS/Prototypes/TargetList.py
+++ b/EXOSIMS/Prototypes/TargetList.py
@@ -5,17 +5,17 @@
 from EXOSIMS.util.getExoplanetArchive import getExoplanetArchiveAliases
 from EXOSIMS.util.utils import genHexStr
 from MeanStars import MeanStars
+from synphot import Observation, SourceSpectrum, SpectralElement
+from synphot.models import BlackBodyNorm1D
+from synphot.units import VEGAMAG
+from synphot.exceptions import DisjointError
 import numpy as np
 import astropy.units as u
-import astropy.constants as const
 from astropy.time import Time
 from astropy.coordinates import SkyCoord
-from astropy.io import fits
-import re
 import os.path
 import json
 from pathlib import Path
-from scipy.optimize import root_scalar
 from tqdm import tqdm
 import pickle
 import pkg_resources
@@ -46,6 +46,15 @@ class TargetList(object):
         fillPhotometry (bool):
             Attempt to fill in missing photometric data for targets from
             available values (primarily spectral type and luminosity). Defaults False.
+        fillMissingBandMags (bool):
+            If ``fillPhotometry`` is True, also fill in missing band magnitudes.
+            Ignored if ``fillPhotometry`` is False.  Defaults False.
+
+            .. warning::
+
+                This can be used for generating more complete target data for other uses
+                but is *not* recommended for use in integration time calculations.
+
         explainFiltering (bool):
             Print informational messages at each target list filtering step.
             Defaults False.
@@ -121,13 +130,13 @@ class TargetList(object):
             Used in upstream modules.  Alias for filter_for_char.
         explainFiltering (bool):
             Print informational messages at each target list filtering step.
-        F0dict (dict):
-            Internal storage of pre-computed zero-mag flux values that is populated
-            each time an F0 is requested for a particular target.
         fillPhotometry (bool):
             Attempt to fill in missing target photometric  values using interpolants of
             tabulated values for the stellar type. See MeanStars documentation for
             more details.
+        fillMissingBandMags (bool):
+            If ``self.fillPhotometry`` is True, also fill in missing band magnitudes.
+            Ignored if ``self.fillPhotometry`` is False.
         filter_for_char (bool):
             Use spectroscopy observation mode (rather than the default detection mode)
             for all calculations.
@@ -220,18 +229,20 @@ class TargetList(object):
             Dictionary of spectral types
         specindex (dict):
             Index of spectral types
-        speclist (list):
-            List of spectral types available
-        specliste (numpy.ndarray):
-            Available spectral types split in class, subclass, and luminosity class
         spectral_class (numpy.ndarray):
             nStars x 3. First column is spectral class, second is spectral subclass and
             third is luminosity class.
         spectypenum (numpy.ndarray):
             Numerical value of spectral type for matching
+        star_fluxes (dict):
+            Internal storage of pre-computed star flux values that is populated
+            each time a flux is requested for a particular target. Keyed by observing
+            mode hex attribute.
         staticStars (bool):
             Do not apply proper motions to stars.  Stars always at mission start time
             positions.
+        Teff (astropy.units.Quantity):
+            Stellar effective temperature.
         Umag (numpy.ndarray):
             U band magnitudes
         Vmag (numpy.ndarray):
@@ -249,6 +260,7 @@ def __init__(
         staticStars=True,
         keepStarCatalog=False,
         fillPhotometry=False,
+        fillMissingBandMags=False,
         explainFiltering=False,
         filterBinaries=True,
         cachedir=None,
@@ -274,34 +286,24 @@ def __init__(
         # load the vprint function (same line in all prototype module constructors)
         self.vprint = vprint(specs.get("verbose", True))
 
-        # validate TargetList boolean flags
-        assert isinstance(staticStars, bool), "staticStars must be a boolean."
-        assert isinstance(keepStarCatalog, bool), "keepStarCatalog must be a boolean."
-        assert isinstance(fillPhotometry, bool), "fillPhotometry must be a boolean."
-        assert isinstance(explainFiltering, bool), "explainFiltering must be a boolean."
-        assert isinstance(filterBinaries, bool), "filterBinaries must be a boolean."
-        assert isinstance(getKnownPlanets, bool), "getKnownPlanets must be a boolean."
-
+        # assign inputs to attributes
         self.getKnownPlanets = bool(getKnownPlanets)
         self.staticStars = bool(staticStars)
         self.keepStarCatalog = bool(keepStarCatalog)
         self.fillPhotometry = bool(fillPhotometry)
+        self.fillMissingBandMags = bool(fillMissingBandMags)
         self.explainFiltering = bool(explainFiltering)
         self.filterBinaries = bool(filterBinaries)
         self.filter_for_char = bool(filter_for_char)
         self.earths_only = bool(earths_only)
+        self.scaleWAdMag = bool(scaleWAdMag)
+        self.int_dMag_offset = float(int_dMag_offset)
 
         # list of target names to remove from targetlist
-        if popStars:
+        if popStars is not None:
             assert isinstance(popStars, list), "popStars must be a list."
         self.popStars = popStars
 
-        # This parameter is used to modify the dMag value used to calculate
-        # integration time
-        self.int_dMag_offset = int_dMag_offset
-        # Flag for whether to do luminosity scaling
-        self.scaleWAdMag = scaleWAdMag
-
         # populate outspec
         for att in self.__dict__:
             if att not in ["vprint", "_outspec"]:
@@ -309,59 +311,23 @@ def __init__(
                 self._outspec[att] = dat.value if isinstance(dat, u.Quantity) else dat
 
         # set up stuff for spectral type conversion
-        # Craete a MeanStars object for future use:
+        # Create a MeanStars object for future use
         self.ms = MeanStars()
-
-        # Paths
-        indexf = pkg_resources.resource_filename(
-            "EXOSIMS.TargetList", "pickles_index.pkl"
-        )
-        assert os.path.exists(
-            indexf
-        ), "Pickles catalog index file not found in TargetList directory."
-
-        datapath = pkg_resources.resource_filename("EXOSIMS.TargetList", "dat_uvk")
-        assert os.path.isdir(
-            datapath
-        ), "Could not locate %s in TargetList directory." % (datapath)
-
-        # grab Pickles Atlas index
-        with open(indexf, "rb") as handle:
-            self.specindex = pickle.load(handle)
-
-        self.speclist = sorted(self.specindex.keys())
-        self.specdatapath = datapath
-
-        # spectral type decomposition
-        # default string: Letter|number|roman numeral
-        # number is either x, x.x, x/x
-        # roman numeral is either
-        # either number of numeral can be wrapped in ()
-        self.specregex1 = re.compile(
-            r"([OBAFGKMLTY])\s*\(*(\d*\.\d+|\d+|\d+\/\d+)\)*\s*\(*([IV]+\/{0,1}[IV]*)"
-        )
-        # next option is that you have something like 'G8/K0IV'
-        self.specregex2 = re.compile(
-            r"([OBAFGKMLTY])\s*(\d+)\/[OBAFGKMLTY]\s*\d+\s*\(*([IV]+\/{0,1}[IV]*)"
-        )
-        # next down the list, just try to match leading vals and assume it's a dwarf
-        self.specregex3 = re.compile(r"([OBAFGKMLTY])\s*(\d*\.\d+|\d+|\d+\/\d+)")
-        # last resort is just match spec type
-        self.specregex4 = re.compile(r"([OBAFGKMLTY])")
-
+        # Define a helper dictionary for spectral classes
         self.specdict = {"O": 0, "B": 1, "A": 2, "F": 3, "G": 4, "K": 5, "M": 6}
+        # figure out what template spectra we have access to
+        self.load_spectral_catalog()
+        # set up standard photometric bands
+        self.load_standard_bands()
+        # Create internal storage for speeding up spectral flux  calculations.
+        # This dictionary is for storing SourceSpectrum objects (values) by spectral
+        # types (keys). This will be populated as spectra are loaded.
+        self.template_spectra = {}
+        # This dictionary is for storing target-specific fluxes for observing mode
+        # bands. keys are mod['hex'] values. values are arrays equal in size to the
+        # current targetlist. This will be populated as calculations are performed.
+        self.star_fluxes = {}
 
-        # everything in speclist is correct, so only need first regexp
-        specliste = []
-        for spec in self.speclist:
-            specliste.append(self.specregex1.match(spec).groups())
-        self.specliste = np.vstack(specliste)
-        self.spectypenum = np.array(
-            [self.specdict[ll] for ll in self.specliste[:, 0]]
-        ) * 10 + np.array(self.specliste[:, 1]).astype(float)
-
-        # Create F0 dictionary for storing mode-associated F0s
-        self.F0dict = {}
         # get desired module names (specific or prototype) and instantiate objects
         self.StarCatalog = get_module(specs["modules"]["StarCatalog"], "StarCatalog")(
             **specs
@@ -452,6 +418,19 @@ def __init__(
                 % self.nStars
             )
 
+        # compute stellar effective temperatures as needed
+        self.stellar_Teff()
+        # calculate 'true' and 'approximate' stellar masses and radii
+        self.stellar_mass()
+        self.stellar_diameter()
+        # Calculate Star System Inclinations
+        self.I = self.gen_inclinations(self.PlanetPopulation.Irange)
+
+        # create placeholder array black-body spectra
+        # (only filled if any modes require it)
+        self.blackbody_spectra = np.ndarray((self.nStars), dtype=object)
+        self.catalog_atts.append("blackbody_spectra")
+
         # Calculate saturation and intCutoff delta mags and completeness values
         self.calc_saturation_and_intCutoff_vals()
 
@@ -459,17 +438,10 @@ def __init__(
         self.int_comp = self.Completeness.target_completeness(self)
         self.catalog_atts.append("int_comp")
 
-        # calculate 'true' and 'approximate' stellar masses
-        self.vprint("Calculating target stellar masses.")
-        self.stellar_mass()
-
-        # Calculate Star System Inclinations
-        self.I = self.gen_inclinations(self.PlanetPopulation.Irange)
-
         # generate any completeness update data needed
         self.Completeness.gen_update(self)
 
-        # apply any requeted additional filters
+        # apply any requested additional filters
         self.filter_target_list(**specs)
 
         # get target system information from exopoanet archive if requested
@@ -516,6 +488,149 @@ def __str__(self):
 
         return "Target List class object attributes"
 
+    def load_spectral_catalog(self):
+        """Helper method for generating a cache of available template spectra and
+        loading them as attributes
+
+        Creates the following attributes:
+
+        #. ``spectral_catalog_index``: A dictionary of spectral types (keys) and the
+           associated spectra files on disk (values)
+        #. ``spectral_catalog_types``: An nx4 ndarray (n is the number of teplate
+           spectra avaiable). First three columns are spectral class (str),
+           subclass (int), and luinosity class (str). The fourth column is a spectral
+           class numeric representation, equaling specdict[specclass]*10 + subclass.
+
+        """
+        spectral_catalog_cache = Path(self.cachedir, "spectral_catalog.pkl")
+        if spectral_catalog_cache.exists():
+            with open(spectral_catalog_cache, "rb") as f:
+                tmp = pickle.load(f)
+                self.spectral_catalog_index = tmp["spectral_catalog_index"]
+                self.spectral_catalog_types = tmp["spectral_catalog_types"]
+        else:
+            pickles_path = pkg_resources.resource_filename(
+                "EXOSIMS.TargetList", "dat_uvk"
+            )
+            bpgs_path = pkg_resources.resource_filename("EXOSIMS.TargetList", "bpgs")
+            spectral_catalog_file = pkg_resources.resource_filename(
+                "EXOSIMS.TargetList", "spectral_catalog_index.json"
+            )
+            assert os.path.isdir(
+                pickles_path
+            ), f"Pickles Atlas path {pickles_path} does not appear to be a directory."
+            assert os.path.isdir(
+                bpgs_path
+            ), f"BPGS Atlas path {bpgs_path} does not appear to be a directory."
+            assert os.path.exists(
+                spectral_catalog_file
+            ), f"Spectral catalog index file {spectral_catalog_file} not found."
+
+            # grab original spectral catalog index
+            with open(spectral_catalog_file, "r") as f:
+                spectral_catalog = json.load(f)
+
+            # assign system-specific paths and repackage catalog info for easier
+            # accessiblity downstream
+            spectral_catalog_index = {}
+            spectral_catalog_types = np.zeros((len(spectral_catalog), 4), dtype=object)
+
+            for j, s in enumerate(spectral_catalog):
+                if spectral_catalog[s]["file"].startswith("pickles"):
+                    spectral_catalog_index[s] = os.path.join(
+                        pickles_path, spectral_catalog[s]["file"]
+                    )
+                else:
+                    spectral_catalog_index[s] = os.path.join(
+                        bpgs_path, spectral_catalog[s]["file"]
+                    )
+                assert os.path.exists(spectral_catalog_index[s])
+
+                spectral_catalog_types[j] = spectral_catalog[s]["specclass"]
+
+            # cache the system-specific values for future use
+            with open(spectral_catalog_cache, "wb") as f:
+                pickle.dump(
+                    {
+                        "spectral_catalog_index": spectral_catalog_index,
+                        "spectral_catalog_types": spectral_catalog_types,
+                    },
+                    f,
+                )
+
+            # now assign catalog values as class attributes
+            self.spectral_catalog_index = spectral_catalog_index
+            self.spectral_catalog_types = spectral_catalog_types
+
+    def get_template_spectrum(self, spec):
+        """Helper method for loading/retrieving spectra from the spectral catalog
+
+        Args:
+            spec (str):
+                Spectral type string. Must be a keys in self.spectral_catalog_index
+        Returns:
+            synphot.SourceSpectrum:
+                Template pectrum from file.
+        """
+
+        if spec not in self.template_spectra:
+            self.template_spectra[spec] = SourceSpectrum.from_file(
+                self.spectral_catalog_index[spec]
+            )
+
+        return self.template_spectra[spec]
+
+    def load_standard_bands(self):
+        """Helper method that defines standard photometric bandpasses
+
+        This method defines the following class attributes:
+
+        #. ``standard_bands_letters``: String with standard band letters
+           (nominally UVBRI)
+        #. ``standard_bands``: A dictionary (key of band letter) whose values are
+           synphot SpectralElement objects for that bandpass.
+        #. ``standard_bands_lam``: An array of band central wavelengths (same order as
+           standard_bands_letters
+        #. ``standard_bands_deltaLam``: An array of band bandwidths (same order as
+           standard_bands_letters.
+
+        """
+
+        band_letters = "UBVRIJHK"
+        band_file_names = [
+            "johnson_u",
+            "johnson_b",
+            "johnson_v",
+            "cousins_r",
+            "cousins_i",
+            "bessel_j",
+            "bessel_h",
+            "bessel_k",
+        ]
+        self.standard_bands = {}
+        for b, bf in zip(band_letters, band_file_names):
+            self.standard_bands[b] = SpectralElement.from_filter(bf)
+
+        self.standard_bands_lam = (
+            np.array(
+                [
+                    self.standard_bands[b].avgwave().to(u.nm).value
+                    for b in self.standard_bands
+                ]
+            )
+            * u.nm
+        )
+        self.standard_bands_deltaLam = (
+            np.array(
+                [
+                    self.standard_bands[b].rectwidth().to(u.nm).value
+                    for b in self.standard_bands
+                ]
+            )
+            * u.nm
+        )
+        self.standard_bands_letters = band_letters
+
     def set_catalog_attributes(self):
         """Hepler method that sets possible and required catalog attributes.
 
@@ -627,6 +742,7 @@ def calc_saturation_and_intCutoff_vals(self):
         self.catalog_atts.append("int_WA")
 
         # grab required modules and determine which observing mode to use
+        # also populate inputs for calculations
         OS = self.OpticalSystem
         ZL = self.ZodiacalLight
         PPop = self.PlanetPopulation
@@ -641,10 +757,36 @@ def calc_saturation_and_intCutoff_vals(self):
             mode = detmode
             self.calc_char_int_comp = False
 
+        # grab zodi vals for any required calculations
+        sInds = np.arange(self.nStars)
+        fZminglobal = ZL.global_zodi_min(mode)
+        fZ = np.repeat(fZminglobal, len(sInds))
+        fEZ = np.repeat(ZL.fEZ0, len(sInds))
+
+        # compute proj separation bounds for any required calculations
+        if PPop.scaleOrbits:
+            tmp_smin = np.tan(mode["IWA"]) * self.dist / np.sqrt(self.L)
+            if np.isinf(mode["OWA"]):
+                tmp_smax = np.inf * self.dist
+            else:
+                tmp_smax = np.tan(mode["OWA"]) * self.dist / np.sqrt(self.L)
+        else:
+            tmp_smin = np.tan(mode["IWA"]) * self.dist
+            if np.isinf(mode["OWA"]):
+                tmp_smax = np.inf * self.dist
+            else:
+                tmp_smax = np.tan(mode["OWA"]) * self.dist
+
         # 1. Calculate the saturation dMag. This is stricly a function of
-        # fZminglobal, ZL.fEZ0, self.int_WA, mode, and the current targetlist
+        # fZminglobal, ZL.fEZ0, self.int_WA, mode, the current targetlist
+        # and the postprocessing factor
         zodi_vals_str = f"{str(ZL.global_zodi_min(mode))} {str(ZL.fEZ0)}"
-        stars_str = f"fillPhotometry:{self.fillPhotometry}" + ",".join(self.Name)
+        stars_str = (
+            f"ppFact:{self.PostProcessing._outspec['ppFact']}, "
+            f"fillPhotometry:{self.fillPhotometry}, "
+            f"fillMissingBandMags:{self.fillMissingBandMags}"
+            ",".join(self.Name)
+        )
         int_WA_str = ",".join(self.int_WA.value.astype(str)) + str(self.int_WA.unit)
 
         # cache filename is the three class names, the vals hash, and the mode hash
@@ -655,23 +797,25 @@ def calc_saturation_and_intCutoff_vals(self):
             f"vals_{vals_hash}_mode_{mode['hex']}"
         )
 
-        self.saturation_dMag = self.calc_saturation_dMag(mode, fname)
+        saturation_dMag_path = Path(self.cachedir, f"{fname}.sat_dMag")
+        if saturation_dMag_path.exists():
+            with open(saturation_dMag_path, "rb") as f:
+                self.saturation_dMag = pickle.load(f)
+            self.vprint(f"Loaded saturation_dMag values from {saturation_dMag_path}")
+        else:
+            self.saturation_dMag = OS.calc_saturation_dMag(
+                self, sInds, fZ, fEZ, self.int_WA, mode, TK=None
+            )
+
+            with open(saturation_dMag_path, "wb") as f:
+                pickle.dump(self.saturation_dMag, f)
+            self.vprint(f"saturation_dMag values stored in {saturation_dMag_path}")
 
         # 2. Calculate the completeness value if the star is integrated for an
         # infinite time by using the saturation dMag
         if PPop.scaleOrbits:
-            tmp_smin = np.tan(mode["IWA"]) * self.dist / np.sqrt(self.L)
-            if np.isinf(mode["OWA"]):
-                tmp_smax = np.inf * self.dist
-            else:
-                tmp_smax = np.tan(mode["OWA"]) * self.dist / np.sqrt(self.L)
             tmp_dMag = self.saturation_dMag - 2.5 * np.log10(self.L)
         else:
-            tmp_smin = np.tan(mode["IWA"]) * self.dist
-            if np.isinf(mode["OWA"]):
-                tmp_smax = np.inf * self.dist
-            else:
-                tmp_smax = np.tan(mode["OWA"]) * self.dist
             tmp_dMag = self.saturation_dMag
 
         # cache filename is the two class names and the vals hash
@@ -690,9 +834,9 @@ def calc_saturation_and_intCutoff_vals(self):
         # calculate or load from disk if cache exists
         saturation_comp_path = Path(self.cachedir, f"{fname}.sat_comp")
         if saturation_comp_path.exists():
-            self.vprint(f"Loaded saturation_comp values from {saturation_comp_path}")
             with open(saturation_comp_path, "rb") as f:
                 self.saturation_comp = pickle.load(f)
+            self.vprint(f"Loaded saturation_comp values from {saturation_comp_path}")
         else:
             self.vprint("Calculating the saturation time completeness")
             self.saturation_comp = Comp.comp_calc(
@@ -714,19 +858,51 @@ def calc_saturation_and_intCutoff_vals(self):
             f"vals_{vals_hash}_mode_{mode['hex']}"
         )
 
-        self.intCutoff_dMag = self.calc_intCutoff_dMag(mode, fname)
+        intCutoff_dMag_path = Path(self.cachedir, f"{fname}.intCutoff_dMag")
+        if intCutoff_dMag_path.exists():
+            with open(intCutoff_dMag_path, "rb") as f:
+                self.intCutoff_dMag = pickle.load(f)
+            self.vprint(f"Loaded intCutoff_dMag values from {intCutoff_dMag_path}")
+        else:
+            self.vprint("Calculating intCutoff_dMag")
+            intTimes = np.repeat(OS.intCutoff.value, len(sInds)) * OS.intCutoff.unit
+
+            self.intCutoff_dMag = OS.calc_dMag_per_intTime(
+                intTimes, self, sInds, fZ, fEZ, self.int_WA, mode
+            ).reshape((len(intTimes),))
+            with open(intCutoff_dMag_path, "wb") as f:
+                pickle.dump(self.intCutoff_dMag, f)
+            self.vprint(f"intCutoff_dMag values stored in {intCutoff_dMag_path}")
 
         # 4. Calculate intCutoff completeness. This is a function of the exact same
         # things as the previous calculation, so we can recycle the filename
+        if PPop.scaleOrbits:
+            tmp_dMag = self.intCutoff_dMag - 2.5 * np.log10(self.L)
+        else:
+            tmp_dMag = self.intCutoff_dMag
+
+        # cache filename is the two class names and the vals hash
+        intcutoffcomp_valstr = (
+            ",".join(tmp_smin.to(u.AU).value.astype(str))
+            + ",".join(tmp_smax.to(u.AU).value.astype(str))
+            + ",".join(tmp_dMag.astype(str))
+        )
+
+        vals_hash = genHexStr(stars_str + intcutoffcomp_valstr)
+        fname = (
+            f"TargetList_{self.StarCatalog.__class__.__name__}_"
+            f"{Comp.__class__.__name__}_vals_{vals_hash}"
+        )
+
         intCutoff_comp_path = Path(self.cachedir, f"{fname}.intCutoff_comp")
         if intCutoff_comp_path.exists():
-            self.vprint(f"Loaded intCutoff_comp values from {intCutoff_comp_path}")
             with open(intCutoff_comp_path, "rb") as f:
                 self.intCutoff_comp = pickle.load(f)
+            self.vprint(f"Loaded intCutoff_comp values from {intCutoff_comp_path}")
         else:
             self.vprint("Calculating the integration cutoff time completeness")
             self.intCutoff_comp = Comp.comp_calc(
-                tmp_smin.to(u.AU).value, tmp_smax.to(u.AU).value, self.intCutoff_dMag
+                tmp_smin.to(u.AU).value, tmp_smax.to(u.AU).value, tmp_dMag
             )
             with open(intCutoff_comp_path, "wb") as f:
                 pickle.dump(self.intCutoff_comp, f)
@@ -769,25 +945,6 @@ def calc_saturation_and_intCutoff_vals(self):
                 self.intCutoff_dMag - self.int_dMag_offset + 2.5 * np.log10(self.L)
             )
 
-        # if requested, rescale based on luminosities and mode limits
-        # Commented out until a better understanding of where this came from is
-        # available. Block above is a simplified version of this logic
-        # if self.scaleWAdMag:
-        # for i,Lstar in enumerate(self.L):
-        # if (Lstar < 6.85) and (Lstar > 0.):
-        # self.int_dMag[i] = self.intCutoff_dMag[i] - self.int_dMag_offset + 2.5 * np.log10(Lstar)   # noqa: E501
-        # else:
-        # self.int_dMag[i] = self.intCutoff_dMag[i]
-
-        # EEID = ((np.sqrt(Lstar)*u.AU/self.dist[i]).decompose()*u.rad).to(u.arcsec)
-        # if EEID < detmode['IWA']:
-        # EEID = detmode['IWA']*(1.+1e-14)
-        # elif EEID > detmode['OWA']:
-        # EEID = detmode['OWA']*(1.-1e-14)
-
-        # self.int_WA[i] = EEID
-        # self._outspec['scaleWAdMag'] = self.scaleWAdMag
-
         # Go through the int_dMag values and replace with limiting dMag where
         # int_dMag is higher. Since the int_dMag will never be reached if
         # intCutoff_dMag is below it
@@ -801,107 +958,6 @@ def calc_saturation_and_intCutoff_vals(self):
         self.catalog_atts.append("saturation_dMag")
         self.catalog_atts.append("saturation_comp")
 
-    def F0(self, BW, lam, spec=None):
-        """
-        This function calculates the spectral flux density for a given
-        spectral type. Assumes the Pickles Atlas is saved to TargetList:
-        ftp://ftp.stsci.edu/cdbs/grid/pickles/dat_uvk/
-
-        If spectral type is provided, tries to match based on luminosity class,
-        then spectral type. If no type, or not match, defaults to fit based on
-        Traub et al. 2016 (JATIS), which gives spectral flux density of
-        ~9.5e7 [ph/s/m2/nm] @ 500nm
-
-
-        Args:
-            BW (float):
-                Bandwidth fraction
-            lam (~astropy.units.Quantity):
-                Central wavelength in units of nm
-            Spec (str):
-                Spectral type. Should be something like G0V
-
-        Returns:
-            ~astropy.units.Quantity:
-                Spectral flux density in units of ph/m**2/s/nm.
-        """
-
-        if spec is not None:
-            # Try to decmompose the input spectral type
-            tmp = self.specregex1.match(spec)
-            if not (tmp):
-                tmp = self.specregex2.match(spec)
-            if tmp:
-                spece = [
-                    tmp.groups()[0],
-                    float(tmp.groups()[1].split("/")[0]),
-                    tmp.groups()[2].split("/")[0],
-                ]
-            else:
-                tmp = self.specregex3.match(spec)
-                if tmp:
-                    spece = [tmp.groups()[0], float(tmp.groups()[1].split("/")[0]), "V"]
-                else:
-                    tmp = self.specregex4.match(spec)
-                    if tmp:
-                        spece = [tmp.groups()[0], 0, "V"]
-                    else:
-                        spece = None
-
-            # now match to the atlas
-            if spece is not None:
-                lumclass = self.specliste[:, 2] == spece[2]
-                if not np.any(lumclass):
-                    specmatch = None
-                else:
-                    ind = np.argmin(
-                        np.abs(
-                            self.spectypenum[lumclass]
-                            - (self.specdict[spece[0]] * 10 + spece[1])
-                        )
-                    )
-                    specmatch = "".join(self.specliste[lumclass][ind])
-            else:
-                specmatch = None
-        else:
-            specmatch = None
-
-        if specmatch is None:
-            F0 = (
-                1e4
-                * 10 ** (4.01 - (lam / u.nm - 550) / 770)
-                * u.ph
-                / u.s
-                / u.m**2
-                / u.nm
-            )
-        else:
-            # Open corresponding spectrum
-            with fits.open(
-                os.path.join(self.specdatapath, self.specindex[specmatch])
-            ) as hdulist:
-                sdat = hdulist[1].data
-
-            # Reimann integration of spectrum within bandwidth, converted from
-            # erg/s/cm**2/angstrom to ph/s/m**2/nm, where dlam in nm is the
-            # variable of integration.
-            lmin = lam * (1 - BW / 2)
-            lmax = lam * (1 + BW / 2)
-
-            # midpoint Reimann sum
-            band = (sdat.WAVELENGTH >= lmin.to(u.Angstrom).value) & (
-                sdat.WAVELENGTH <= lmax.to(u.Angstrom).value
-            )
-            ls = sdat.WAVELENGTH[band] * u.Angstrom
-            Fs = (sdat.FLUX[band] * u.erg / u.s / u.cm**2 / u.AA) * (
-                ls / const.h / const.c
-            )
-            F0 = (
-                np.sum((Fs[1:] + Fs[:-1]) * np.diff(ls) / 2.0) / (lmax - lmin) * u.ph
-            ).to(u.ph / u.s / u.m**2 / u.nm)
-
-        return F0
-
     def fillPhotometryVals(self):
         """
         Attempts to determine the spectral class and luminosity class of each
@@ -918,6 +974,9 @@ def fillPhotometryVals(self):
         Eric Mamajek (JPL/Caltech, University of Rochester)
 
         See MeanStars documentation for futher details.
+
+        TODO: only use MeanStars for dwarfs. Otherwise use spectra.
+
         """
 
         # first let's try to establish the spectral type
@@ -930,6 +989,7 @@ def fillPhotometryVals(self):
                     self.spectral_class[j] = tmp
                 elif self.fillPhotometry:
                     # if we have a luminosity, try to reconstruct from that
+                    # otherwise try for B-V color
                     if not (np.isnan(self.L[j])) and (self.L[j] != 0):
                         ind = self.ms.tableLookup("logL", np.log10(self.L[j]))
                         self.spectral_class[j] = self.ms.MK[ind], self.ms.MKn[ind], "V"
@@ -953,7 +1013,14 @@ def fillPhotometryVals(self):
 
         # remove all subdwarfs and white-dwarfs
         sInds = np.array(
-            [j for j in range(self.nStars) if self.spectral_class[j, 0] in "OBAFGKM"]
+            [
+                j
+                for j in range(self.nStars)
+                if (
+                    (self.spectral_class[j, 0] in "OBAFGKM")
+                    and (self.spectral_class[j, 2] not in ["VI", "VII"])
+                )
+            ]
         )
         self.revise_lists(sInds)
         if self.explainFiltering:
@@ -968,6 +1035,16 @@ def fillPhotometryVals(self):
             [f"{s[0]}{int(np.round(s[1]))}{s[2]}" for s in self.spectral_class]
         )
 
+        # Add fourth column to spectral_class with the numerical class value
+        # defined as specdict[specclass] * 10 + specsubclass
+        spectypenum = [
+            self.specdict[c] * 10 + sc
+            for c, sc in zip(self.spectral_class[:, 0], self.spectral_class[:, 1])
+        ]
+        self.spectral_class = np.hstack(
+            (self.spectral_class, np.array(spectypenum, ndmin=2).transpose())
+        )
+
         # if we don't need to fill photometry values, we're done here
         if not (self.fillPhotometry):
             return
@@ -1039,7 +1116,11 @@ def fillPhotometryVals(self):
                     "BCv", self.spectral_class[i][0], self.spectral_class[i][1]
                 )
 
-        # and finally, get as many other bands as we can from table colors
+        # if we don't need to fill band mag values, we're done here
+        if not (self.fillMissingBandMags):
+            return
+
+        # finally, get as many other bands as we can from table colors
         mag_atts = ["Kmag", "Hmag", "Jmag", "Imag", "Umag", "Rmag"]
         # these start-end colors to calculate for each band
         colors_to_add = [
@@ -1257,8 +1338,8 @@ def revise_lists(self, sInds):
         for att in self.catalog_atts:
             if getattr(self, att).size != 0:
                 setattr(self, att, getattr(self, att)[sInds])
-        for key in self.F0dict:
-            self.F0dict[key] = self.F0dict[key][sInds]
+        for key in self.star_fluxes:
+            self.star_fluxes[key] = self.star_fluxes[key][sInds]
         try:
             self.Completeness.revise_updates(sInds)
         except AttributeError:
@@ -1268,10 +1349,13 @@ def revise_lists(self, sInds):
     def stellar_mass(self):
         """Populates target list with 'true' and 'approximate' stellar masses
 
-        This method calculates stellar mass via the formula relating absolute V
-        magnitude and stellar mass.  The values are in units of solar mass.
+        Approximate stellar masses are calculated from absolute magnitudes using the
+        model from [Henry1993]_. "True" masses are generated by a uniformly distributed,
+        7%-mean error term to the apprxoimate masses.
 
-        Function called by reset sim
+        All values are in units of solar mass.
+
+        Function called by reset sim.
 
         """
 
@@ -1289,6 +1373,67 @@ def stellar_mass(self):
         if not hasattr(self.catalog_atts, "MsTrue"):
             self.catalog_atts.append("MsTrue")
 
+    def stellar_diameter(self):
+        """Populates target list with approximate stellar diameters
+
+        Stellar radii are computed using the BV target colors according to the model
+        from [Boyajian2014]_.  This model has a standard deviation error of 7.8%.
+
+        Updates/creates attribute ``diameter``, as needed.
+
+        """
+
+        # if any diameters were populated from the star catalog, do not
+        # overwrite those
+        if hasattr(self, "diameter"):
+            sInds = np.where(np.isnan(self.diameter) & self.diameter == 0)[0]
+        else:
+            sInds = np.arange(self.nStars)
+            self.diameter = np.zeros(self.nStars) * u.mas
+
+        if "diameter" not in self.catalog_atts:
+            self.catalog_atts.append("diameter")
+
+        # B-V polynomial fit coefficients:
+        coeffs = [0.49612, 1.11136, -1.18694, 0.91974, -0.19526]
+
+        # Evaluate eq. 2 using B-V color
+        logth_zero = np.zeros(sInds.shape)
+        for j, ai in enumerate(coeffs):
+            logth_zero += ai * self.BV[sInds] ** j
+
+        # now invert eq. 1 to get the actual diameter in mas
+        self.diameter[sInds] = 10 ** (logth_zero - 0.2 * self.Vmag[sInds]) * u.mas
+
+    def stellar_Teff(self):
+        """Calculate the effective stellar temperature based on B-V color.
+
+        This method uses the empirical fit from [Ballesteros2012]_
+        doi:10.1209/0295-5075/97/34008
+
+        Updates/creates attribute ``Teff``, as needed.
+        """
+
+        # if any effective temperatures were populated from the star catalog, do not
+        # overwrite those
+        if hasattr(self, "Teff"):
+            sInds = np.where(np.isnan(self.Teff) & self.Teff == 0)[0]
+        else:
+            sInds = np.arange(self.nStars)
+            self.Teff = np.zeros(self.nStars) * u.K
+
+        if "Teff" not in self.catalog_atts:
+            self.catalog_atts.append("Teff")
+
+        self.Teff[sInds] = (
+            4600.0
+            * u.K
+            * (
+                1.0 / (0.92 * self.BV[sInds] + 1.7)
+                + 1.0 / (0.92 * self.BV[sInds] + 0.62)
+            )
+        )
+
     def starprop(self, sInds, currentTime, eclip=False):
         """Finds target star positions vector in heliocentric equatorial (default)
         or ecliptic frame for current time (MJD).
@@ -1392,8 +1537,8 @@ def starprop(self, sInds, currentTime, eclip=False):
                 )
             return r_targ
 
-    def starF0(self, sInds, mode):
-        """Return the spectral flux density of the requested stars for the
+    def starFlux(self, sInds, mode):
+        """Return the total spectral flux of the requested stars for the
         given observing mode.  Caches results internally for faster access in
         subsequent calls.
 
@@ -1401,95 +1546,89 @@ def starF0(self, sInds, mode):
             sInds (~numpy.ndarray(int)):
                 Indices of the stars of interest
             mode (dict):
-                Observing mode dictionary (see OpticalSystem)
+                Observing mode dictionary (see :ref:`OpticalSystem`)
 
         Returns:
             ~astropy.units.Quantity(~numpy.ndarray(float)):
-                Spectral flux densities in units of ph/m**2/s/nm.
+                Spectral fluxes in units of ph/m**2/s.
 
         """
 
-        if mode["hex"] in self.F0dict:
-            tmp = np.isnan(self.F0dict[mode["hex"]][sInds])
-            if np.any(tmp):
-                inds = np.where(tmp)[0]
-                for j in inds:
-                    self.F0dict[mode["hex"]][sInds[j]] = self.F0(
-                        mode["BW"], mode["lam"], spec=self.Spec[sInds[j]]
-                    )
-        else:
-            self.F0dict[mode["hex"]] = np.full(self.nStars, np.nan) * (
-                u.ph / u.s / u.m**2 / u.nm
+        # If we've never been asked for fluxes in this mode before, create a new array
+        # of flux values for it and set them all to nan.
+        if mode["hex"] not in self.star_fluxes:
+            self.star_fluxes[mode["hex"]] = np.full(self.nStars, np.nan) * (
+                u.ph / u.s / u.m**2
             )
-            for j in sInds:
-                self.F0dict[mode["hex"]][j] = self.F0(
-                    mode["BW"], mode["lam"], spec=self.Spec[j]
-                )
-
-        return self.F0dict[mode["hex"]][sInds]
-
-    def starMag(self, sInds, lam):
-        r"""Calculates star visual magnitudes with B-V color using empirical fit
-        to data from Pecaut and Mamajek (2013, Appendix C).
-        The expression for flux is accurate to about 7%, in the range of validity
-        400 nm < :math:`\lambda` < 1000 nm (Traub et al. 2016).
-
-        Args:
-            sInds (~numpy.ndarray(int)):
-                Indices of the stars of interest
-            lam (astropy Quantity):
-                Wavelength in units of nm
-
-        Returns:
-            ~numpy.ndarray(float):
-                Star magnitudes at wavelength from B-V color
-
-        """
-
-        # cast sInds to array
-        sInds = np.array(sInds, ndmin=1, copy=False)
-
-        Vmag = self.Vmag[sInds]
-        BV = self.BV[sInds]
-
-        lam_um = lam.to("um").value
-        if lam_um < 0.550:
-            b = 2.20
-        else:
-            b = 1.54
-        mV = Vmag + b * BV * (1.0 / lam_um - 1.818)
-
-        return mV
-
-    def stellarTeff(self, sInds):
-        """Calculate the effective stellar temperature based on B-V color.
-
-        This method uses the empirical fit from Ballesteros (2012)
-        doi:10.1209/0295-5075/97/34008
 
-        Args:
-            sInds (~numpy.ndarray(int)):
-                Indices of the stars of interest
-
-        Returns:
-            ~astropy.units.Quantity(~numpy.ndarray(float)):
-                Stellar effective temperatures in degrees K
+        # figure out which target indices (if any) need new calculations to be done
+        novals = np.isnan(self.star_fluxes[mode["hex"]][sInds])
+        inds = np.unique(sInds[novals])  # calculations needed for these sInds
+        if len(inds) > 0:
+            # find out distances (in wavelength) between standard bands and mode
+            band_dists = np.abs(self.standard_bands_lam - mode["lam"]).to(u.nm).value
+            # this is our order of preferred band manigutdes to use
+            band_pref_inds = np.argsort(band_dists)
+
+            # now loop through all required calculations and pick the best approach
+            # for each one
+            for sInd in tqdm(inds, "Computing star fluxes", delay=2):
+                # try each band in descending preference order until you get a valid
+                # magnitude value
+                for band_ind in band_pref_inds:
+                    mag_to_use = None
+                    tmp = getattr(self, f"{self.standard_bands_letters[band_ind]}mag")
+                    if np.all(tmp == 0):
+                        continue
+                    if not (np.isnan(tmp[sInd])):
+                        band_to_use = self.standard_bands_letters[band_ind]
+                        mag_to_use = tmp[sInd]
+                        break
+                assert (
+                    mag_to_use is not None
+                ), f"No valid magnitudes found for {self.Name[sInd]}"
+
+                # if bandpass goes beyond 2.4 microns, use black-body spectrum
+                if mode["bandpass"].waveset.max() > 2.4 * u.um:
+                    if self.blackbody_spectra[sInd] is None:
+                        self.blackbody_spectra[sInd] = SourceSpectrum(
+                            BlackBodyNorm1D, temperature=self.Teff[sInd]
+                        )
+                    template = self.blackbody_spectra[sInd]
+                else:
+                    # find the closest template spectrum
+                    if self.Spec[sInd] in self.spectral_catalog_index:
+                        spec_to_use = self.Spec[sInd]
+                    else:
+                        row = self.spectral_catalog_types[
+                            np.argmin(
+                                np.abs(
+                                    self.spectral_catalog_types[:, 3]
+                                    - self.spectral_class[sInd][3]
+                                )
+                            )
+                        ]
+                        spec_to_use = f"{row[0]}{row[1]}{row[2]}"
+
+                    # load the template
+                    template = self.get_template_spectrum(spec_to_use)
+
+                # renormalize the template to the band we've decided to use
+                template_renorm = template.normalize(
+                    mag_to_use * VEGAMAG,
+                    self.standard_bands[band_to_use],
+                    vegaspec=self.OpticalSystem.vega_spectrum,
+                )
 
-        """
+                # finally, write the result back to the star_fluxes
+                try:
+                    self.star_fluxes[mode["hex"]][sInd] = Observation(
+                        template_renorm, mode["bandpass"], force="taper"
+                    ).integrate()
+                except DisjointError:
+                    self.star_fluxes[mode["hex"]][sInd] = 0 * (u.ph / u.s / u.m**2)
 
-        # cast sInds to array
-        sInds = np.array(sInds, ndmin=1, copy=False)
-
-        Teff = (
-            4600.0
-            * u.K
-            * (
-                1.0 / (0.92 * self.BV[sInds] + 1.7)
-                + 1.0 / (0.92 * self.BV[sInds] + 0.62)
-            )
-        )
-
-        return Teff
+        return self.star_fluxes[mode["hex"]][sInds]
 
     def radiusFromMass(self, sInds):
         """Estimates the star radius based on its mass
@@ -1506,10 +1645,10 @@ def radiusFromMass(self, sInds):
                 Star radius estimates
         """
 
-        M = self.MsTrue[sInds].value  # Always use this??
+        M = self.MsEst[sInds].value  # use MsEst as that's the deterministic one
         a = -0.073
         b = 0.668
-        starRadius = 10 ** (a + b * np.log(M))
+        starRadius = 10 ** (a + b * np.log10(M))
 
         return starRadius * u.R_sun
 
@@ -1616,7 +1755,7 @@ def calc_HZ(self, sInds, S, A, B, C, arcsec=False):
         # cast sInds to array
         sInds = np.array(sInds, ndmin=1, copy=False)
 
-        T_eff = self.stellarTeff(sInds)
+        T_eff = self.Teff[sInds]
 
         T_star = (5780 * u.K - T_eff).to(u.K).value
 
@@ -1796,130 +1935,3 @@ def queryNEAsystems(self):
 
             else:
                 self.targetAliases[j] = []
-
-    def calc_intCutoff_dMag(self, mode, fname):
-        """
-        This calculates the delta magnitude for each target star that
-        corresponds to the cutoff integration time. Uses the working
-        angle, int_WA, used to calculate integration times.
-
-        Args:
-            mode (dict):
-                Observing mode dictionary (see OpticalSystem)
-            fname (str):
-                Filename for caching results. Note that this should be just the base of
-                the filename.  The full path to the cache directory (including the
-                appropriate extension) is determined in this method.
-
-        Returns:
-            ~numpy.ndarray(float):
-                Array with dMag values if exposed for the integration cutoff time
-                for each target star
-        """
-
-        intCutoff_dMag_path = Path(self.cachedir, f"{fname}.intCutoff_dMag")
-        if intCutoff_dMag_path.exists():
-            self.vprint(f"Loaded intCutoff_dMag values from {intCutoff_dMag_path}")
-            with open(intCutoff_dMag_path, "rb") as f:
-                intCutoff_dMag = pickle.load(f)
-        else:
-            self.vprint("Calculating intCutoff_dMag")
-
-            OS = self.OpticalSystem
-            ZL = self.ZodiacalLight
-            intTime = OS.intCutoff
-            # Get the fZminglobal value in ZL for the desired mode
-            fZminglobal = ZL.global_zodi_min(mode)
-
-            # format inputs
-            sInds = np.arange(self.nStars)
-            intTimes = np.repeat(intTime.value, len(sInds)) * intTime.unit
-            fZ = np.repeat(fZminglobal, len(sInds))
-            fEZ = np.repeat(ZL.fEZ0, len(sInds))
-
-            intCutoff_dMag = OS.calc_dMag_per_intTime(
-                intTimes, self, sInds, fZ, fEZ, self.int_WA, mode
-            ).reshape((len(intTimes),))
-            with open(intCutoff_dMag_path, "wb") as f:
-                pickle.dump(intCutoff_dMag, f)
-            self.vprint(f"intCutoff_dMag values stored in {intCutoff_dMag_path}")
-        return intCutoff_dMag
-
-    def calc_saturation_dMag(self, mode, fname):
-        """
-        This calculates the delta magnitude for each target star that
-        corresponds to an infinite integration time. Uses the working
-        angle, int_WA, used to calculate integration times.
-
-        Args:
-            mode (dict):
-                Observing mode dictionary (see :ref:`OpticalSystem`)
-            fname (str):
-                Filename for caching results. Note that this should be just the base of
-                the filename.  The full path to the cache directory (including the
-                appropriate extension) is determined in this method.
-
-        Returns:
-            ~numpy.ndarray(float):
-                Array with dMag values if exposed for the integration cutoff time for
-                each target star
-        """
-        saturation_dMag_path = Path(self.cachedir, f"{fname}.sat_dMag")
-        if saturation_dMag_path.exists():
-            self.vprint(f"Loaded saturation_dMag values from {saturation_dMag_path}")
-            with open(saturation_dMag_path, "rb") as f:
-                saturation_dMag = pickle.load(f)
-        else:
-            OS = self.OpticalSystem
-            ZL = self.ZodiacalLight
-            sInds = np.arange(self.nStars)
-
-            fZminglobal = ZL.global_zodi_min(mode)
-            fZ = np.repeat(fZminglobal, len(sInds))
-            fEZ = np.repeat(ZL.fEZ0, len(sInds))
-
-            saturation_dMag = np.zeros(len(sInds))
-            if mode["syst"].get("occulter"):
-                saturation_dMag = np.full(shape=len(sInds), fill_value=np.inf)
-            else:
-                for i, sInd in enumerate(
-                    tqdm(sInds, desc="Calculating saturation_dMag")
-                ):
-                    args = (
-                        self,
-                        [sInd],
-                        [fZ[i].value] * fZ.unit,
-                        [fEZ[i].value] * fEZ.unit,
-                        [self.int_WA[i].value] * self.int_WA.unit,
-                        mode,
-                        None,
-                    )
-                    singularity_res = root_scalar(
-                        OS.int_time_denom_obj,
-                        args=args,
-                        method="brentq",
-                        bracket=[10, 40],
-                    )
-                    singularity_dMag = singularity_res.root
-                    saturation_dMag[i] = singularity_dMag
-
-            # This block is not relevant w/ current implementation, but this
-            # will create an interpolant of the saturation dMag as a function
-            # of WA
-            # initial_WA_range = np.linspace(mode['IWA'], mode['OWA'], 5)
-            # next_WA_range = np.linspace(initial_WA_range[0], initial_WA_range[1], 4)
-            # WA_range = np.unique(np.concatenate((next_WA_range, initial_WA_range)))
-            # saturation_dMags = []
-            # for WA in WA_range:
-            # args = (self, 0, fZminglobal, ZL.fEZ0, WA, mode, None)
-            # singularity_res = root_scalar(OS.int_time_denom_obj,
-            # args=args, method='brentq',
-            # bracket=[10, 40])
-            # singularity_dMag = singularity_res.root
-            # saturation_dMags.append(singularity_dMag)
-            # saturation_dMag_curve = scipy.interpolate.interp1d(WA_range,
-            #                                                   saturation_dMags)
-            with open(saturation_dMag_path, "wb") as f:
-                pickle.dump(saturation_dMag, f)
-            self.vprint(f"saturation_dMag values stored in {saturation_dMag_path}")
-        return saturation_dMag
diff --git a/EXOSIMS/Prototypes/ZodiacalLight.py b/EXOSIMS/Prototypes/ZodiacalLight.py
index 9c5b0ee4d..070867ff0 100644
--- a/EXOSIMS/Prototypes/ZodiacalLight.py
+++ b/EXOSIMS/Prototypes/ZodiacalLight.py
@@ -7,7 +7,8 @@
 import pickle
 import pkg_resources
 from astropy.time import Time
-from scipy.interpolate import interp1d
+from scipy.interpolate import griddata, interp1d
+from synphot import units
 import sys
 
 
@@ -88,20 +89,18 @@ def __init__(
         self.magZ = float(magZ)  # 1 zodi brightness (per arcsec2)
         self.magEZ = float(magEZ)  # 1 exo-zodi brightness (per arcsec2)
         self.varEZ = float(varEZ)  # exo-zodi variation (variance of log-normal dist)
-        self.fZ0 = 10 ** (-0.4 * self.magZ) / u.arcsec**2  # default zodi brightness
-        self.fEZ0 = (
-            10 ** (-0.4 * self.magEZ) / u.arcsec**2
-        )  # default exo-zodi brightness
+        assert self.varEZ >= 0, "Exozodi variation must be >= 0"
 
+        self.fZ0 = 10 ** (-0.4 * self.magZ) / u.arcsec**2  # default zodi brightness
+        # default exo-zodi brightness
+        self.fEZ0 = 10 ** (-0.4 * self.magEZ) / u.arcsec**2
+        # global zodi minimum
         self.global_min = 10 ** (-0.4 * self.magZ)
         self.fZMap = {}
         self.fZTimes = Time(np.array([]), format="mjd", scale="tai")
 
-        assert self.varEZ >= 0, "Exozodi variation must be >= 0"
-
         # Common Star System Number of Exo-zodi
         self.commonSystemfEZ = commonSystemfEZ  # ZL.nEZ must be calculated in SU
-        self._outspec["commonSystemfEZ"] = self.commonSystemfEZ
 
         # populate outspec
         for att in self.__dict__:
@@ -116,7 +115,10 @@ def __init__(
             ]:
                 dat = self.__dict__[att]
                 self._outspec[att] = dat.value if isinstance(dat, u.Quantity) else dat
-        self.logf = self.calclogf()  # create an interpolant for the wavelength
+
+        # Load zodi data
+        self.load_zodi_wavelength_data()
+        self.load_zodi_spatial_data()
 
     def __str__(self):
         """String representation of the Zodiacal Light object
@@ -161,12 +163,15 @@ def fZ(self, Obs, TL, sInds, currentTimeAbs, mode):
             nStars == 1 or nTimes == 1 or nTimes == nStars
         ), "If multiple times and targets, currentTimeAbs and sInds sizes must match."
 
-        nZ = np.ones(np.maximum(nStars, nTimes))
+        # compute correction factors
+        nZ = np.ones(np.maximum(nStars, nTimes)) * self.zodi_color_correction_factor(
+            mode["lam"], photon_units=True
+        )
         fZ = nZ * 10 ** (-0.4 * self.magZ) / u.arcsec**2
 
         return fZ
 
-    def fEZ(self, MV, I, d, alpha=2, tau=1, fbeta=None):  # noqa: E741
+    def fEZ(self, MV, I, d, alpha=2, tau=1):
         """Returns surface brightness of exo-zodiacal light
 
         Args:
@@ -180,9 +185,6 @@ def fEZ(self, MV, I, d, alpha=2, tau=1, fbeta=None):  # noqa: E741
                 power applied to radial distribution, default=2
             tau (float):
                 disk morphology dependent throughput correction factor, default =1
-            fbeta (float, optional):
-                Correction factor for inclination, default is None.
-                If None, is calculated from I according to Eq. 16 of [Savransky2010]_
 
         Returns:
             ~astropy.units.Quantity(~numpy.ndarray(float)):
@@ -200,13 +202,18 @@ def fEZ(self, MV, I, d, alpha=2, tau=1, fbeta=None):  # noqa: E741
         else:
             nEZ = self.gen_systemnEZ(len(MV))
 
-        # supplementary angle for inclination > 90 degrees
+        # inclinations should be strictly in [0, pi], but allow for weird sampling:
         beta = I.to("deg").value
-        mask = np.where(beta > 90)[0]
+        beta[beta > 180] -= 180
+
+        # latitudinal variations are symmetric about 90 degrees so compute the
+        # supplementary angle for inclination > 90 degrees
+        mask = beta > 90
         beta[mask] = 180.0 - beta[mask]
+
+        # finally, the input to the model is 90-inclination
         beta = 90.0 - beta
-        if fbeta is None:
-            fbeta = 2.44 - 0.0403 * beta + 0.000269 * beta**2  # ESD: needs citation?
+        fbeta = self.zodi_latitudinal_correction_factor(beta * u.deg, model="interp")
 
         fEZ = (
             nEZ
@@ -261,13 +268,12 @@ def generate_fZ(self, Obs, TL, TK, mode, hashname, koTimes=None):
             None
 
         Updates Attributes:
-            tuple:
-                fZMap[n, TL.nStars] (~astropy.units.Quantity(~numpy.ndarray(float))):
-                    Surface brightness of zodiacal light in units of 1/arcsec2
-                    for every star for every ko_dtStep
-                fZTimes (~astropy.time.Time(~numpy.ndarray(float)), optional):
-                    Absolute MJD mission times from start to end, updated if koTimes
-                    does not exist
+            fZMap[n, TL.nStars] (~astropy.units.Quantity(~numpy.ndarray(float))):
+                Surface brightness of zodiacal light in units of 1/arcsec2
+                for every star for every ko_dtStep
+            fZTimes (~astropy.time.Time(~numpy.ndarray(float)), optional):
+                Absolute MJD mission times from start to end, updated if koTimes
+                does not exist
         """
 
         # Generate cache Name
@@ -436,10 +442,10 @@ def calcfZmin(self, sInds, Obs, TL, TK, mode, hashname, koMap=None, koTimes=None
                 fZlocalMinInds = [ind for ind in fZlocalMinInds if kogoodStart[ind, i]]
                 # This happens in prototype module. Caused by all values in
                 # fZ_matrix being the same
-                if fZlocalMinInds == []:
+                if len(fZlocalMinInds) == 0:
                     fZlocalMinInds = [0]
 
-                if np.any(fZlocalMinInds):
+                if len(fZlocalMinInds) > 0:
                     fZmins[i, fZlocalMinInds] = fZ_matrix[i, fZlocalMinInds]
                     fZtypes[i, fZlocalMinInds] = 2
 
@@ -451,6 +457,7 @@ def calcfZmin(self, sInds, Obs, TL, TK, mode, hashname, koMap=None, koTimes=None
 
     def extractfZmin(self, fZmins, sInds, koTimes=None):
         """Extract the global fZminimum from fZmins
+
         Args:
             fZmins[n, TL.nStars] (~astropy.units.Quantity(~numpy.ndarray(float))):
                 fZMap, but only fZmin candidates remain. All other values are set to
@@ -494,15 +501,29 @@ def extractfZmin(self, fZmins, sInds, koTimes=None):
             np.asarray(absTimefZmin), format="mjd", scale="tai"
         )
 
-    def calcfbetaInput(self):
-        # table 17 in Leinert et al. (1998)
-        # Zodiacal Light brightness function of solar LON (rows) and LAT (columns)
-        # values given in W m−2 sr−1 μm−1 for a wavelength of 500 nm
+    def load_zodi_spatial_data(self):
+        """
+        Zodi spatial variation data from Table 17 of [Leinert1998]_
+
+        Zodiacal Light brightness as function of solar LON (rows) and LAT (columns)
+        Values are given in 10^-8 W m−2 sr−1 μm−1 at a wavelength of 500 nm
+
+        Attributes:
+            zodi_points (numpy.ndarry):
+                nx2 lat,lon pairs
+            zodi_values (astropy.units.Quantity):
+                n intensity values (units of W/m2/sr/um)
+
+        """
+
+        # Read data from disk
         indexf = pkg_resources.resource_filename(
             "EXOSIMS.ZodiacalLight", "Leinert98_table17.txt"
         )
         Izod = np.loadtxt(indexf) * 1e-8  # W/m2/sr/um
-        # create data point coordinates
+        self.zodi_values = Izod.reshape(Izod.size) * u.Unit("W m-2 sr-1 um-1")
+
+        # create point coordinates
         lon_pts = np.array(
             [
                 0.0,
@@ -529,22 +550,17 @@ def calcfbetaInput(self):
         lat_pts = np.array([0.0, 5, 10, 15, 20, 25, 30, 45, 60, 75, 90])  # deg
         y_pts, x_pts = np.meshgrid(lat_pts, lon_pts)
         points = np.array(list(zip(np.concatenate(x_pts), np.concatenate(y_pts))))
-        # create data values, normalized by (90,0) value due to table encoding
-        z = Izod / Izod[12, 0]
-        values = z.reshape(z.size)
 
-        return points, values
+        self.zodi_points = points * u.deg
 
-    def calclogf(self):
+    def load_zodi_wavelength_data(self):
         """
-        Zodi wavelength dependence, from Table 19 in Leinert et al 1998
+        Zodi wavelength dependence, from Table 19 of [Leinert1998]_
         interpolated w/ a quadratic in log-log space
 
-        Args:
-            None
-        Returns:
-            interpolant (scipy.interpolate.interp1d):
-                a 1D quadratic interpolant of intensity vs wavelength
+        Creates an interpolant (scipy.interpolate.interp1d) assigned to attribute
+        ``logf`` which takes as an argument log_10(wavelength in um) and returns
+        log10(specific intensity in W/m^2/um/sr)
 
         """
         self.zodi_lam = np.array(
@@ -589,7 +605,184 @@ def calclogf(self):
         )  # W/m2/sr/um
         x = np.log10(self.zodi_lam)
         y = np.log10(self.zodi_Blam)
-        return interp1d(x, y, kind="quadratic")
+
+        self.logf = interp1d(x, y, kind="quadratic")
+
+    def zodi_intensity_at_wavelength(self, lam, photon_units=False):
+        """
+        Compute zodiacal light specific intensity as a function of wavelength
+
+        Args:
+            lam (astropy.units.Quantity):
+                Wavelength(s) of interest
+            photon_units(bool):
+                Convert all quantities to photon units before computing ratio.
+                Defaults False (leave all quantities in power units).
+
+        Returns:
+            astropy.units.Quantity:
+                Specific intensity of zodiacal light at requested wavelength(s).
+                Has same dimension as input. Default units of W m-2 um-1 sr-1 if
+                ``photon_units`` is False, otherwise  ph s-1 m-2 um-1 sr-1
+
+        .. warning:
+
+            This method uses the interpolant stored in ``logf`` and defined by method
+            ``load_zodi_wavelength_data``.  This must return intensitites in units of
+            log10(W/m^2/um/sr).
+        """
+
+        val = 10.0 ** (self.logf(np.log10(lam.to("um").value))) * u.Unit(
+            "W m-2 sr-1 um-1"
+        )
+
+        if photon_units:
+            val = (units.convert_flux(lam, val * u.sr, units.PHOTLAM) / u.sr).to(
+                "ph s-1 m-2 um-1 sr-1"
+            )
+
+        return val
+
+    def zodi_color_correction_factor(self, lam, photon_units=False):
+        """
+        Compute zodiacal light color correction factor. This is a multiplicative
+        factor to apply to zodiacal light intensity computed at a reference wavelength
+        (500 nm for the Leinert data used in this prototype).
+
+        Args:
+            lam (astropy.units.Quantity):
+                Wavelength(s) of interest
+            photon_units(bool):
+                Convert all quantities to photon units before computing ratio.
+                Defaults False (leave all quantities in power units).
+
+        Returns:
+            float or numpy.ndarray:
+                Specific intensity of zodiacal light at requested wavelength(s) scaled
+                by the value at the reference wavelength (500 nm).
+                Has same dimension as input.
+
+        .. warning:
+
+            While itself unitless, the units of the original intensities must match
+            those of the intensity or flux being scaled. If the quantity being scaled
+            has power units, ``photon_units`` must be False.
+        """
+
+        fcolor = self.zodi_intensity_at_wavelength(
+            lam, photon_units=photon_units
+        ) / self.zodi_intensity_at_wavelength(0.5 * u.um, photon_units=photon_units)
+
+        return fcolor.value
+
+    def zodi_intensity_at_location(self, lons, lats, photon_units=False):
+        """
+        Compute zodiacal light specific intensity as a function of look vector at
+        reference wavelength (500 nm)
+
+        Args:
+            lons (astropy.units.Quantity):
+                Ecliptic longitude minus solar ecliptic longitude
+            lats (astropy.units.Quantity):
+                Ecliptic latitude.  Must be of same dimension as lons.
+            photon_units(bool):
+                Convert all quantities to photon units before computing ratio.
+                Defaults False (leave all quantities in power units).
+
+        Returns:
+            astropy.units.Quantity:
+                Specific intensity of zodiacal light at requested wavelength(s).
+                Has same dimension as input. Default units of W m-2 um-1 sr-1 if
+                ``photon_units`` is False, otherwise  ph s-1 m-2 um-1 sr-1
+        """
+
+        lons = np.array(lons.to(u.deg).value, ndmin=1)
+        lats = np.array(lats.to(u.deg).value, ndmin=1)
+
+        zodiflux = (
+            griddata(
+                self.zodi_points.value,
+                self.zodi_values.value,
+                np.vstack([lons.flatten(), lats.flatten()]).transpose(),
+            )
+            * self.zodi_values.unit
+        )
+
+        if photon_units:
+            zodiflux = (
+                units.convert_flux(500 * u.nm, zodiflux * u.sr, units.PHOTLAM) / u.sr
+            ).to("ph s-1 m-2 um-1 sr-1")
+
+        return zodiflux.reshape(lons.shape)
+
+    def zodi_latitudinal_correction_factor(self, theta, model=None, interp_at=135):
+        """
+        Compute zodiacal light latitudinal correction factor.  This is a multiplicative
+        factor to apply to zodiacal light intensity to account for the orientation of
+        the dust disk with respect to the observer.
+
+        Args:
+            theta (astropy.units.Quantity):
+                Angle of disk. For local zodi, this is equivalent to the absolute value
+                of the ecliptic latitude of the look vector. For exozodi, this is 90
+                degrees minus the inclination of the orbital plane.
+            model (str, optional):
+                Model to use.  Options are Lindler2006, Stark2014, or interp
+                (case insensitive). See :ref:`zodiandexozodi` for details.
+                Defaults to None
+            interp_at (float):
+                If ``model`` is 'interp', interpolate Leinert Table 17 at this
+                longitude. Defaults to 135.
+
+        Returns:
+            float or numpy.ndarray:
+                Correction factor of zodiacal light at requested angles.
+                Has same dimension as input.
+
+        .. note::
+
+            Unlike the color correction factor, this quantity is wavelength independent
+            and thus does not change if using power or photon units.
+
+        """
+
+        if model is not None:
+            model = model.lower()
+            assert model in [
+                "lindler2006",
+                "stark2014",
+                "interp",
+            ], "Model must be one of Lindler2006, Stark2014, or interp"
+        else:
+            return np.ones(theta.shape)
+
+        if model == "lindler2006":
+            beta = theta.to(u.deg).value
+            fbeta = (2.44 - 0.0403 * beta + 0.000269 * beta**2) / 2.44
+        elif model == "stark2014":
+            fbeta = (
+                1.02
+                - 0.566 * np.sin(theta)
+                - 0.884 * np.sin(theta) ** 2
+                + 0.853 * np.sin(theta) ** 3
+            )
+        else:
+            # figure out the interpolant
+            interpname = f"interp{np.round(interp_at)}"
+            if not hasattr(self, interpname):
+                inds = self.zodi_points[:, 0].value == interp_at
+                vals = self.zodi_values[inds]
+                vals = vals / vals[0]
+                setattr(
+                    self,
+                    interpname,
+                    interp1d(self.zodi_points[inds, 1].value, vals.value, kind="cubic"),
+                )
+
+            fbetafun = getattr(self, interpname)
+            fbeta = fbetafun(theta.to(u.deg).value)
+
+        return fbeta
 
     def global_zodi_min(self, mode):
         """
@@ -605,6 +798,10 @@ def global_zodi_min(self, mode):
                 The global minimum zodiacal light value for the observing mode,
                 in (1/arcsec**2)
         """
-        fZminglobal = 10 ** (-0.4 * self.magZ) / u.arcsec**2
+        fZminglobal = (
+            self.zodi_color_correction_factor(mode["lam"], photon_units=True)
+            * 10 ** (-0.4 * self.magZ)
+            / u.arcsec**2
+        )
 
         return fZminglobal
diff --git a/EXOSIMS/Scripts/Roman_Nemati2019_knownRV.json b/EXOSIMS/Scripts/Roman_Nemati2019_knownRV.json
index b5fbdfc07..4ee55efc5 100644
--- a/EXOSIMS/Scripts/Roman_Nemati2019_knownRV.json
+++ b/EXOSIMS/Scripts/Roman_Nemati2019_knownRV.json
@@ -1,113 +1,94 @@
 {
   "minComp": 0,
-  "dMagLim": 22.5,
   "pupilDiam": 2.363,
   "obscurFac": 0.1840,
   "ppFact": 0.5,
-  "dMag0": 18.25257498916000,
   "ref_dMag": 3.0,
   "ref_Time": 0.2,
-  "WA0": 0.55,
   "magZ": 23,
   "magEZ": 22.17,
   "ContrastScenario": "",
   "scienceInstruments": [
-    { "name": "imager",
+    {
+      "name": "imager",
       "QE": "$HOME/Downloads/romandemo/e2v_Spec.fits",
       "FoV": 9.5,
       "pixelNumber": 1024,
       "pixelSize": 1.3e-5,
       "sread": 1.7e-6,
-      "kRN": 75,
       "idark": 3e-5,
       "CIC": 2.1e-3,
-      "CTE_derate": 0.93,
-      "dark_derate": 1,
-      "refl_derate": 1,
       "MUF_thruput": 0.909,
       "lam_d": 575.855987,
       "lam_c": 508,
-	  "k_samp": 0.20,
+      "k_samp": 0.20,
       "texp": 3,
       "ENF": 1.0,
-	  "THPUT": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/THPT_EB_PhB_IMG_NF_B1_180718.csv",
-	  "DET": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/DET_CBE_200318.csv"
+      "THPUT": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/THPT_EB_PhB_IMG_NF_B1_180718.csv",
+      "DET": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/DET_CBE_200318.csv"
     },
-    { "name": "spectro",
+    {
+      "name": "spectro",
       "QE": "$HOME/Downloads/romandemo/e2v_Spec.fits",
       "FoV": 1,
       "pixelNumber": 76,
       "pixelSize": 1.74e-4,
       "sread": 1.7e-6,
-      "kRN": 75,
       "idark": 3e-5,
       "CIC": 2.1e-3,
-      "CTE_derate": 0.93,
-      "dark_derate": 1,
-      "refl_derate": 1,
       "MUF_thruput": 0.909090909090909,
       "lam_d": 575.855987,
       "lam_c": 660,
-	  "k_samp": 0.20,
+      "k_samp": 0.20,
       "texp": 14,
       "ENF": 1.0,
       "Rs": 50,
-	  "THPUT": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/THPT_EB_IMG_WF_B4_180718.csv",
-	  "DET": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/DET_CBE_200318.csv"
-	  
+      "THPUT": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/THPT_EB_IMG_WF_B4_180718.csv",
+      "DET": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/DET_CBE_200318.csv"
     },
-    { "name": "amici-spec",
+    {
+      "name": "amici-spec",
       "QE": "$HOME/Downloads/romandemo/e2v_Spec.fits",
       "FoV": 1,
       "pixelNumber": 1024,
       "pixelSize": 1.3e-5,
       "sread": 1.7e-6,
-      "kRN": 75,
       "idark": 3e-5,
       "CIC": 2.1e-3,
-      "CTE_derate": 0.93,
-      "dark_derate": 1,
-      "refl_derate": 1,
       "MUF_thruput": 0.909,
       "lam_d": 729.9691083746,
       "lam_c": 730,
-	  "k_samp": 0.20,
+      "k_samp": 0.20,
       "texp": 40,
       "ENF": 1.0,
-	  "pupilDiam": 0.005,
-	  "focal":0.26,
-	  "Fnum": 52,
+      "fnumber": 52,
       "Rs": 50,
-	  "THPUT": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/THPT_EB_SPB3_200730.csv",
-	  "DET": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/DET_CBE_200318.csv"
+      "THPUT": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/THPT_EB_SPB3_200730.csv",
+      "DET": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/DET_CBE_200318.csv"
     },
-    { "name": "WF-imager",
-      "QE_old": "$HOME/Downloads/romandemo/e2v_Spec.fits",
-	  "QE": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/QE_e2v_Spec.csv",
+    {
+      "name": "WF-imager",
+      "QE": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/QE_e2v_Spec.csv",
       "FoV": 1,
       "pixelNumber": 1024,
       "pixelSize": 1.3e-5,
       "sread": 1.7e-6,
-      "kRN": 75,
       "idark": 3e-5,
       "CIC": 2.1e-3,
-      "CTE_derate": 0.93,
-      "dark_derate": 1,
-      "refl_derate": 1,
       "MUF_thruput": 0.909,
       "lam_d": 822.56322,
       "lam_c": 500,
-	  "k_samp": 0.30,
+      "k_samp": 0.30,
       "texp": 14,
       "ENF": 1.0,
       "Rs": 50,
-	  "THPUT": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/THPT_EB_IMG_WF_B4_180718.csv",
-	  "DET": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/DET_CBE_200318.csv"
+      "THPUT": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/THPT_EB_IMG_WF_B4_180718.csv",
+      "DET": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/DET_CBE_200318.csv"
     }
-
   ],
   "starlightSuppressionSystems": [
-    { "name": "HLC-565",
+    {
+      "name": "HLC-565",
       "lam": 575,
       "BW": 0.10,
       "Rs": 0,
@@ -121,11 +102,12 @@
       "core_area": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/CGPERF_HLC_PhB_20190129.csv",
       "core_contrast": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/CGPERF_HLC_PhB_20190129.csv",
       "core_platescale": 0.30,
-	  "core_stability": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Cstability/CS_HLC_NF_IMG.csv",
-	  "core_stability_setting": "MCBE",
-	  "CG_Perf": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/CGPERF_HLC_PhB_20190129.csv"
+      "core_stability": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Cstability/CS_HLC_NF_IMG.csv",
+      "core_stability_setting": "MCBE",
+      "CG_Perf": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/CGPERF_HLC_PhB_20190129.csv"
     },
-    { "name": "SPC_PhB_20190130",
+    {
+      "name": "SPC_PhB_20190130",
       "lam": 730,
       "BW": 0.15,
       "Nlensl": 5.0,
@@ -136,10 +118,11 @@
       "core_area": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/CGPERF_SPEC_20200617.csv",
       "core_contrast": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/CGPERF_SPEC_20200617.csv",
       "core_platescale": 0.30,
-	  "core_stability": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Cstability/CS_SPC_SPEC_200930.csv",
-	  "core_stability_setting": "MCBE"
+      "core_stability": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Cstability/CS_SPC_SPEC_200930.csv",
+      "core_stability_setting": "MCBE"
     },
-    { "name": "SPC_WFOV",
+    {
+      "name": "SPC_WFOV",
       "lam": 825,
       "BW": 0.1,
       "Rs": 0,
@@ -153,53 +136,54 @@
       "core_area": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/CGPERF_SPC_WFOV_180103.csv",
       "core_contrast": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Photometry/CGPERF_SPC_WFOV_180103.csv",
       "core_platescale": 0.30,
-	  "core_stability": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Cstability/CS_SPC_WF_IMG.csv",
-	  "core_stability_setting": "MCBE"
+      "core_stability": "$HOME/Downloads/romandemo/EBcsvData/csv_fix/Cstability/CS_SPC_WF_IMG.csv",
+      "core_stability_setting": "MCBE"
     }
   ],
   "observingModes": [
-    { "instName": "imager",
+    {
+      "instName": "imager",
       "systName": "HLC-565",
       "detectionMode": true,
-	  "ContrastScenario": "2019_PDR_Update",
+      "ContrastScenario": "2019_PDR_Update",
       "SNR": 10,
-	  "tau_pol": 1,
-	  "GCRFlux": 5,
-	  "photons_per_relativistic_event": 250,
-	  "luminescingOpticalArea": 0.7854,
-	  "OpticalThickness": 4,
-	  "luminescingOpticalDistance": 0.1,
-	  "s_baffling": 0.001
+      "tau_pol": 1,
+      "GCRFlux": 5,
+      "photons_per_relativistic_event": 250,
+      "luminescingOpticalArea": 0.7854,
+      "OpticalThickness": 4,
+      "luminescingOpticalDistance": 0.1,
+      "s_baffling": 0.001
     },
-    {"instName": "amici-spec",
+    {
+      "instName": "amici-spec",
       "systName": "SPC_PhB_20190130",
-	  "ContrastScenario": "2019_PDR_Update",
+      "ContrastScenario": "2019_PDR_Update",
       "lam": 730,
       "SNR": 10,
       "BW": 0.15,
-	  "tau_pol": 1,
-	  "GCRFlux": 5,
-	  "photons_per_relativistic_event": 250,
-	  "luminescingOpticalArea": 0.7854,
-	  "OpticalThickness": 4,
-	  "luminescingOpticalDistance": 0.1,
-	  "s_baffling": 0.001
+      "tau_pol": 1,
+      "GCRFlux": 5,
+      "photons_per_relativistic_event": 250,
+      "luminescingOpticalArea": 0.7854,
+      "OpticalThickness": 4,
+      "luminescingOpticalDistance": 0.1,
+      "s_baffling": 0.001
     },
-    { "instName": "WF-imager",
+    {
+      "instName": "WF-imager",
       "systName": "SPC_WFOV",
       "lam": 825,
       "SNR": 10,
-	  "tau_pol": 1,
-	  "GCRFlux": 5,
-	  "photons_per_relativistic_event": 250,
-	  "luminescingOpticalArea": 0.7854,
-	  "OpticalThickness": 4,
-	  "luminescingOpticalDistance": 0.1,
-	  "s_baffling": 0.001
+      "tau_pol": 1,
+      "GCRFlux": 5,
+      "photons_per_relativistic_event": 250,
+      "luminescingOpticalArea": 0.7854,
+      "OpticalThickness": 4,
+      "luminescingOpticalDistance": 0.1,
+      "s_baffling": 0.001
     }
   ],
-
-  
   "modules": {
     "PlanetPopulation": "KnownRVPlanets",
     "StarCatalog": " ",
diff --git a/EXOSIMS/Scripts/TestScripts/03_EarthTwin_Coronagraph_GarrettComp.json b/EXOSIMS/Scripts/TestScripts/03_EarthTwin_Coronagraph_GarrettComp.json
index e257c0d97..41591dd11 100644
--- a/EXOSIMS/Scripts/TestScripts/03_EarthTwin_Coronagraph_GarrettComp.json
+++ b/EXOSIMS/Scripts/TestScripts/03_EarthTwin_Coronagraph_GarrettComp.json
@@ -16,7 +16,8 @@
       "OWA": 0.5,
       "occ_trans": 0.2,
       "core_thruput": 0.02,
-      "core_mean_intensity": 1e-12,
+      "core_mean_intensity": 4e-14,
+      "core_platescale": 0.33, 
       "core_area": 0.005
     }
   ],
diff --git a/EXOSIMS/Scripts/TestScripts/05_SAG13_Forecaster_BrownCompleteness.json b/EXOSIMS/Scripts/TestScripts/05_SAG13_Forecaster_BrownCompleteness.json
index db3746b08..6f5e45b27 100644
--- a/EXOSIMS/Scripts/TestScripts/05_SAG13_Forecaster_BrownCompleteness.json
+++ b/EXOSIMS/Scripts/TestScripts/05_SAG13_Forecaster_BrownCompleteness.json
@@ -17,6 +17,7 @@
       "occ_trans": 0.2,
       "core_thruput": 0.02,
       "core_mean_intensity": 1e-12,
+      "core_platescale": 0.1, 
       "core_area": 0.005
     }
   ],
diff --git a/EXOSIMS/Scripts/WFIRST_Nemati_KeplerLike.json b/EXOSIMS/Scripts/WFIRST_Nemati_KeplerLike.json
index 8cf773c44..16d3b9770 100644
--- a/EXOSIMS/Scripts/WFIRST_Nemati_KeplerLike.json
+++ b/EXOSIMS/Scripts/WFIRST_Nemati_KeplerLike.json
@@ -12,7 +12,6 @@
   "ref_Time": 0.2,
   
   "minComp": 0,
-  "dMagLim": 22.5,
   
   "scienceInstruments": [
     { "name": "imager",
@@ -96,4 +95,4 @@
     "SurveySimulation": " ",
     "SurveyEnsemble": " "
   }
-}
\ No newline at end of file
+}
diff --git a/EXOSIMS/Scripts/WFIRST_Nemati_KnownRV.json b/EXOSIMS/Scripts/WFIRST_Nemati_KnownRV.json
index 3aa679392..fc6b0c378 100644
--- a/EXOSIMS/Scripts/WFIRST_Nemati_KnownRV.json
+++ b/EXOSIMS/Scripts/WFIRST_Nemati_KnownRV.json
@@ -12,7 +12,6 @@
   "ref_Time": 0.2,
   
   "minComp": 0,
-  "dMagLim": 22.5,
   
   "scienceInstruments": [
     { "name": "imager",
@@ -96,4 +95,4 @@
     "SurveySimulation": "KnownRVSurvey",
     "SurveyEnsemble": " "
   }
-}
\ No newline at end of file
+}
diff --git a/EXOSIMS/Scripts/sS_JTwin.json b/EXOSIMS/Scripts/sS_JTwin.json
index 03b06c15b..790ee4308 100644
--- a/EXOSIMS/Scripts/sS_JTwin.json
+++ b/EXOSIMS/Scripts/sS_JTwin.json
@@ -7,15 +7,12 @@
   "obscurFac": 0.1724,
   "intCutoff": 30,
   "ppFact": 0.1,
-  "WAint":0.3,
+  "int_WA":0.3,
   "minComp": 0,
-  "dMagLim": 22.5,
-  "dMagint": 22.5,
-  "dMag0": 22.5,
+  "int_dMag": 22.5,
   "settlingTime": 0.5, 
   "arange":[0.1,30],
   "erange":[0,0.35],
-  "smaknee":10,
   "scaleOrbits":true,
   "scienceInstruments": [
     { "name": "imager",
diff --git a/EXOSIMS/Scripts/sampleScript_coron.json b/EXOSIMS/Scripts/sampleScript_coron.json
index b39222e8c..9d69f6842 100644
--- a/EXOSIMS/Scripts/sampleScript_coron.json
+++ b/EXOSIMS/Scripts/sampleScript_coron.json
@@ -5,7 +5,6 @@
   "missionPortion": 0.5,
   "OBduration": 50,
   "minComp": 0,
-  "dMagLim": 22.5,
   "pupilDiam": 2.37,
   "ppFact": 0.1,
   "scienceInstruments": [
diff --git a/EXOSIMS/Scripts/sampleScript_occulter.json b/EXOSIMS/Scripts/sampleScript_occulter.json
index 7b5119b58..a8b30f83f 100644
--- a/EXOSIMS/Scripts/sampleScript_occulter.json
+++ b/EXOSIMS/Scripts/sampleScript_occulter.json
@@ -3,7 +3,6 @@
   "missionPortion": 1,
   "OBduration": 50,
   "minComp": 0,
-  "dMagLim": 22.5,
   "pupilDiam": 2.37,
   "ppFact": 0.1,
   "thrust": 450,
@@ -49,15 +48,7 @@
       "core_thruput": 0.69,
       "core_contrast": 1e-10,
       "core_area": 0.002,
-      "core_platescale": 0.30,
-      "occulterDiameter": 26,
-      "NocculterDistances" : 1,
-      "occulterDistances": [
-        {
-        "occulterDistance": 55000,
-        "occulterRedEdge": 690,
-        "occulterBlueEdge": 450 }
-      ]
+      "core_platescale": 0.30
     }
   ],
   "observingModes": [
diff --git a/EXOSIMS/Scripts/sampleScript_parallel_ensemble.json b/EXOSIMS/Scripts/sampleScript_parallel_ensemble.json
index 945d35540..d5168ceef 100644
--- a/EXOSIMS/Scripts/sampleScript_parallel_ensemble.json
+++ b/EXOSIMS/Scripts/sampleScript_parallel_ensemble.json
@@ -3,7 +3,6 @@
   "missionPortion": 1,
   "OBduration": 50,
   "minComp": 0,
-  "dMagLim": 22.5,
   "pupilDiam": 2.37,
   "ppFact": 0.1,
   "scienceInstruments": [
@@ -82,4 +81,4 @@
     "SurveySimulation": " ",
     "SurveyEnsemble": "IPClusterEnsemble"
   }
-}
\ No newline at end of file
+}
diff --git a/EXOSIMS/Scripts/sampleScript_parallel_ensemble2.json b/EXOSIMS/Scripts/sampleScript_parallel_ensemble2.json
index 7e13b2312..370701b72 100644
--- a/EXOSIMS/Scripts/sampleScript_parallel_ensemble2.json
+++ b/EXOSIMS/Scripts/sampleScript_parallel_ensemble2.json
@@ -3,7 +3,6 @@
   "missionPortion": 1,
   "OBduration": 50,
   "minComp": 0,
-  "dMagLim": 22.5,
   "pupilDiam": 2.37,
   "ppFact": 0.1,
   "scienceInstruments": [
diff --git a/EXOSIMS/Scripts/sampleScript_sureThing.json b/EXOSIMS/Scripts/sampleScript_sureThing.json
index 9040d1998..6e96cf231 100644
--- a/EXOSIMS/Scripts/sampleScript_sureThing.json
+++ b/EXOSIMS/Scripts/sampleScript_sureThing.json
@@ -4,7 +4,6 @@
   "missionLife": 0.1,
   "missionPortion": 0.5,
   "minComp": 0.1,
-  "dMagLim": 26,
   "pupilDiam": 8.0,
   "ppFact": 0.1,
   "scienceInstruments": [
diff --git a/EXOSIMS/Scripts/template_SotoStarshadeSK.json b/EXOSIMS/Scripts/template_SotoStarshadeSK.json
index f9fc8de8b..b9d50cbf9 100644
--- a/EXOSIMS/Scripts/template_SotoStarshadeSK.json
+++ b/EXOSIMS/Scripts/template_SotoStarshadeSK.json
@@ -3,13 +3,13 @@
   "missionPortion": 0.5,
   "OBduration": 7,
   "minComp": 0.022,
-  "dMagLim": 22.5,
   "staticStars": true,
   "keepStarCatalog": true,
   "eclip":true,
   "pupilDiam": 2.37,
   "ppFact": 0.1,
-  "koAngleMax":90,
+  "koAngles_Sun":[45, 90],
+  "koAngles_Earth":[45, 90],
   "settlingTime":0.35,
   "thrust": 450,
   "slewIsp": 308,
@@ -19,7 +19,6 @@
   "occulterSep": 76600,
   "skIsp": 220,
   "defburnPortion":0.05,
-  "nSteps":1,
   
   "scienceInstruments": [
     { "name": "imager",
@@ -56,15 +55,7 @@
       "core_thruput": 0.69,
       "core_contrast": 1e-10,
       "core_area": 0.002,
-      "core_platescale": 0.30,
-      "occulterDiameter": 26,
-      "NocculterDistances" : 1,
-      "occulterDistances": [
-        {
-        "occulterDistance": 55000,
-        "occulterRedEdge": 690,
-        "occulterBlueEdge": 450 }
-      ]
+      "core_platescale": 0.30
     }
   ],
   "observingModes": [
@@ -85,7 +76,7 @@
     "ZodiacalLight": "Stark",
     "BackgroundSources": "GalaxiesFaintStars",
     "PlanetPhysicalModel": "FortneyMarleyCahoyMix1",
-    "Observatory": "SotoStarshade_SK",
+    "Observatory": "SotoStarshade_SKi",
     "TimeKeeping": " ",
     "PostProcessing": " ",
     "Completeness": "GarrettCompleteness",
diff --git a/EXOSIMS/Scripts/template_SotoStarshadeSKi.json b/EXOSIMS/Scripts/template_SotoStarshadeSKi.json
index 4ed316ff9..94cf069d0 100644
--- a/EXOSIMS/Scripts/template_SotoStarshadeSKi.json
+++ b/EXOSIMS/Scripts/template_SotoStarshadeSKi.json
@@ -3,13 +3,13 @@
   "missionPortion": 0.5,
   "OBduration": 7,
   "minComp": 0.022,
-  "dMagLim": 22.5,
   "staticStars": true,
   "keepStarCatalog": true,
   "eclip":true,
   "pupilDiam": 2.37,
   "ppFact": 0.1,
-  "koAngleMax":90,
+  "koAngles_Sun":[45, 90],
+  "koAngles_Earth":[45, 90],
   "settlingTime":0.35,
   "thrust": 450,
   "slewIsp": 308,
@@ -19,7 +19,6 @@
   "occulterSep": 76600,
   "skIsp": 220,
   "defburnPortion":0.05,
-  "nSteps":1,
   "missionStart":64041,
   "equinox":64041,
   
@@ -58,15 +57,7 @@
       "core_thruput": 0.69,
       "core_contrast": 1e-10,
       "core_area": 0.002,
-      "core_platescale": 0.30,
-      "occulterDiameter": 26,
-      "NocculterDistances" : 1,
-      "occulterDistances": [
-        {
-        "occulterDistance": 55000,
-        "occulterRedEdge": 690,
-        "occulterBlueEdge": 450 }
-      ]
+      "core_platescale": 0.30
     }
   ],
   "observingModes": [
diff --git a/EXOSIMS/Scripts/template_WFIRST_EarthTwinHabZone.json b/EXOSIMS/Scripts/template_WFIRST_EarthTwinHabZone.json
index 242d738f1..dd918c307 100644
--- a/EXOSIMS/Scripts/template_WFIRST_EarthTwinHabZone.json
+++ b/EXOSIMS/Scripts/template_WFIRST_EarthTwinHabZone.json
@@ -3,10 +3,9 @@
   "missionPortion": 0.1666667,
   "OBduration": 50,
   "minComp": 0,
-  "dMagLim": 22.5,
   "pupilDiam": 2.37,
   "obscurFac": 0.1024,
-  "attenuation": 0.441,
+  "optics": 0.441,
   "ppFact": 0.1,
   "scienceInstruments": [
     { "name": "imager",
@@ -92,4 +91,4 @@
     "SurveySimulation": " ",
     "SurveyEnsemble": " "
   }
-}
\ No newline at end of file
+}
diff --git a/EXOSIMS/Scripts/template_WFIRST_KeplerLike.json b/EXOSIMS/Scripts/template_WFIRST_KeplerLike.json
index 968f9f501..00e543466 100644
--- a/EXOSIMS/Scripts/template_WFIRST_KeplerLike.json
+++ b/EXOSIMS/Scripts/template_WFIRST_KeplerLike.json
@@ -3,10 +3,9 @@
   "missionPortion": 0.1666667,
   "OBduration": 50,
   "minComp": 0,
-  "dMagLim": 22.5,
   "pupilDiam": 2.37,
   "obscurFac": 0.1024,
-  "attenuation": 0.441,
+  "optics": 0.441,
   "ppFact": 0.1,
   "scienceInstruments": [
     { "name": "imager",
@@ -92,4 +91,4 @@
     "SurveySimulation": " ",
     "SurveyEnsemble": " "
   }
-}
\ No newline at end of file
+}
diff --git a/EXOSIMS/Scripts/template_WFIRST_KnownRV.json b/EXOSIMS/Scripts/template_WFIRST_KnownRV.json
index f895e8ca2..160c11335 100644
--- a/EXOSIMS/Scripts/template_WFIRST_KnownRV.json
+++ b/EXOSIMS/Scripts/template_WFIRST_KnownRV.json
@@ -3,10 +3,9 @@
   "missionPortion": 0.1666667,
   "OBduration": 50,
   "minComp": 0,
-  "dMagLim": 22.5,
   "pupilDiam": 2.37,
   "obscurFac": 0.1024,
-  "attenuation": 0.441,
+  "optics": 0.441,
   "ppFact": 0.1,
   "scienceInstruments": [
     { "name": "imager",
@@ -92,4 +91,4 @@
     "SurveySimulation": " ",
     "SurveyEnsemble": " "
   }
-}
\ No newline at end of file
+}
diff --git a/EXOSIMS/SurveySimulation/linearJScheduler_3DDPC.py b/EXOSIMS/SurveySimulation/linearJScheduler_3DDPC.py
index 56f3ad51f..73f637b00 100644
--- a/EXOSIMS/SurveySimulation/linearJScheduler_3DDPC.py
+++ b/EXOSIMS/SurveySimulation/linearJScheduler_3DDPC.py
@@ -432,6 +432,7 @@ def next_target(self, old_sInd, modes):
                     intTimes[mode_sInds] = self.calc_targ_intTime(
                         mode_sInds, startTimes[mode_sInds], mode
                     )
+                    intTimes[np.isnan(intTimes)] = 0 * u.d
                     mode_sInds = mode_sInds[
                         np.where(intTimes[mode_sInds] <= maxIntTime)
                     ]  # Filters targets exceeding end of OB
diff --git a/EXOSIMS/SurveySimulation/linearJScheduler_DDPC.py b/EXOSIMS/SurveySimulation/linearJScheduler_DDPC.py
index 8c78c169d..2eb0f9766 100644
--- a/EXOSIMS/SurveySimulation/linearJScheduler_DDPC.py
+++ b/EXOSIMS/SurveySimulation/linearJScheduler_DDPC.py
@@ -797,6 +797,9 @@ def observation_characterization(self, sInd, modes):
                 intTimes[tochar] = OS.calc_intTime(
                     TL, sInd, fZ[m_i], fEZ, dMag, WA, mode
                 )
+                bad = np.isnan(intTimes)
+                intTimes[bad] = 0 * u.d
+
                 # add a predetermined margin to the integration times
                 intTimes = intTimes * (1 + self.charMargin)
                 # apply time multiplier
@@ -809,6 +812,7 @@ def observation_characterization(self, sInd, modes):
                     (totTimes > 0)
                     & (totTimes <= OS.intCutoff)
                     & (endTimesNorm <= TK.OBendTimes[TK.OBnumber])
+                    & ~bad
                 )
 
                 # 3/ is target still observable at the end of any char time?
diff --git a/EXOSIMS/SurveySimulation/tieredScheduler_sotoSS.py b/EXOSIMS/SurveySimulation/tieredScheduler_sotoSS.py
index cebb79c48..aff9c6fd2 100644
--- a/EXOSIMS/SurveySimulation/tieredScheduler_sotoSS.py
+++ b/EXOSIMS/SurveySimulation/tieredScheduler_sotoSS.py
@@ -1727,47 +1727,3 @@ def scheduleRevisit(self, sInd, smin, det, pInds):
                 self.starRevisit = np.vstack((self.starRevisit, revisit))
             else:
                 self.starRevisit[revInd, 1] = revisit[1]  # over
-
-    def find_known_plans(self):
-        """
-        Find and return list of known RV stars and list of stars with earthlike planets
-        """
-        TL = self.TargetList
-        SU = self.SimulatedUniverse
-
-        c = 28.4 * u.m / u.s
-        Mj = 317.8 * u.earthMass
-        Mpj = SU.Mp / Mj  # planet masses in jupiter mass units
-        Ms = TL.MsTrue[SU.plan2star]
-        Teff = TL.stellarTeff(SU.plan2star)
-        mu = const.G * (SU.Mp + Ms)
-        T = (2.0 * np.pi * np.sqrt(SU.a**3 / mu)).to(u.yr)
-        e = SU.e
-
-        t_filt = np.where((Teff.value > 3000) & (Teff.value < 6800))[
-            0
-        ]  # planets in correct temp range
-
-        K = (
-            (c / np.sqrt(1 - e[t_filt]))
-            * Mpj[t_filt]
-            * np.sin(SU.I[t_filt])
-            * Ms[t_filt] ** (-2 / 3)
-            * T[t_filt] ** (-1 / 3)
-        )
-
-        K_filter = (T[t_filt].to(u.d) / 10**4).value
-        K_filter[np.where(K_filter < 0.03)[0]] = 0.03
-        k_filt = t_filt[
-            np.where(K.value > K_filter)[0]
-        ]  # planets in the correct K range
-
-        a_filt = k_filt[
-            np.where((SU.a[k_filt] > 0.95 * u.AU) & (SU.a[k_filt] < 1.67 * u.AU))[0]
-        ]  # planets in habitable zone
-        r_filt = a_filt[np.where(SU.Rp.value[a_filt] < 1.75)[0]]  # rocky planets
-        self.earth_candidates = np.union1d(self.earth_candidates, r_filt).astype(int)
-
-        known_stars = np.unique(SU.plan2star[k_filt])
-        known_rocky = np.unique(SU.plan2star[r_filt])
-        return known_stars.astype(int), known_rocky.astype(int)
diff --git a/EXOSIMS/TargetList/KnownRVPlanetsTargetList.py b/EXOSIMS/TargetList/KnownRVPlanetsTargetList.py
index 0b39956fc..564f17d64 100644
--- a/EXOSIMS/TargetList/KnownRVPlanetsTargetList.py
+++ b/EXOSIMS/TargetList/KnownRVPlanetsTargetList.py
@@ -50,7 +50,7 @@ def __init__(self, **specs):
         ), "KnownRVPlanetsTargetList must use KnownRVPlanets population"
 
         # Override any bad input attributes
-        attributes_that_must_be_false = ["getKnownPlanets", "fillPhotometry"]
+        attributes_that_must_be_false = ["getKnownPlanets"]
         for att in attributes_that_must_be_false:
             if (att in specs) and (specs[att] is True):
                 warnings.warn(
@@ -140,7 +140,7 @@ def populate_target_list(self, **specs):
         for att in self.atts_mapping:
             ma = tmp[self.atts_mapping[att]]
             if type(ma.fill_value) == np.float64:
-                setattr(self, att, ma.filled(np.ma.median(ma)))
+                setattr(self, att, ma.filled(np.nan))
             else:
                 if (att == "Name") or (att == "Spec"):
                     setattr(self, att, ma.data.astype(str))
diff --git a/EXOSIMS/TargetList/bpgs/BPGS_README b/EXOSIMS/TargetList/bpgs/BPGS_README
new file mode 100644
index 000000000..11deff079
--- /dev/null
+++ b/EXOSIMS/TargetList/bpgs/BPGS_README
@@ -0,0 +1,178 @@
+# Spectral types are taken from Gunn and Stryker 
+# paper, ApJ Supplement 52:121-153, 1983 June
+#Filename_________Target__________Type
+bpgs_1.tab       9-SGR            O5
+bpgs_2.tab       9-SGE            O8F
+bpgs_3.tab       HR8023           O6
+bpgs_4.tab       BD-01D935        B1V
+bpgs_5.tab       60-CYG           B1V
+bpgs_6.tab       102-HER          B2V
+bpgs_7.tab       ETA-HYA          B3V
+bpgs_8.tab       IOTA-HER         B3V
+bpgs_9.tab       HR7899           B4V
+bpgs_10.tab      38-OPH           A1V
+bpgs_11.tab      HR7174           B6V
+bpgs_12.tab      9-VUL            B7V
+bpgs_13.tab      HD189689         B9V
+bpgs_14.tab      THETA-VIR        A0V
+bpgs_15.tab      NU-CAP           B9V
+bpgs_16.tab      HR6169           A2V
+bpgs_17.tab      HD190849A        A1V
+bpgs_18.tab      69-HER           A2V
+bpgs_19.tab      HD190849B        A3V
+bpgs_20.tab      58-AQL           A0V
+bpgs_21.tab      78-HER           B9V
+bpgs_22.tab      HR6570           A7V
+bpgs_23.tab      HD187754         A2V
+bpgs_24.tab      THETA1-SER       A5V
+bpgs_25.tab      PRAESEPE-276
+bpgs_26.tab      PRAESEPE-114
+bpgs_27.tab      PRAESEPE-154
+bpgs_28.tab      HD190192         A5V
+bpgs_29.tab      PRAESEPE-226
+bpgs_30.tab      PRAESEPE-37
+bpgs_31.tab      HD191177         F4V
+bpgs_32.tab      PRAESEPE-332
+bpgs_33.tab      BD+29D3891       F6V
+bpgs_34.tab      PRAESEPE-222
+bpgs_35.tab      HD35296          F8V
+bpgs_36.tab      BD+26D3780       G0V
+bpgs_37.tab      HD148816         F9V
+bpgs_38.tab      HD155675         F8V
+bpgs_39.tab      PRAESEPE-418
+bpgs_40.tab      HYAD-1
+bpgs_41.tab      HD122693         F8V
+bpgs_42.tab      HD154417         F8V
+bpgs_43.tab      HYAD-2
+bpgs_44.tab      HD227547         G5V
+bpgs_45.tab      HD154760         G2V
+bpgs_46.tab      HD190605         G2V
+bpgs_47.tab      HYAD-15
+bpgs_48.tab      HD139777A        K0V
+bpgs_49.tab      HD136274         G8V
+bpgs_50.tab      HYAD-26
+bpgs_51.tab      HD150205         G5V
+bpgs_52.tab      HYAD-21
+bpgs_53.tab      BD+02D3001       G8V
+bpgs_54.tab      HD190571         G8V
+bpgs_55.tab      HYAD-183
+bpgs_56.tab      HD190470         K3V
+bpgs_57.tab      HD154712         K4V
+bpgs_58.tab      HYAD-185
+bpgs_59.tab      BD+38D2457       K8V
+bpgs_60.tab      HYAD-173
+bpgs_61.tab      GL40             M0V
+bpgs_62.tab      HYAD-189
+bpgs_63.tab      HD151288         K7V
+bpgs_64.tab      HD157881         K7V
+bpgs_65.tab      HD132683         M0V
+bpgs_66.tab      GL15A            M0V
+bpgs_67.tab      GL49             M2V
+bpgs_68.tab      GL109            M4V
+bpgs_69.tab      GL15B            M6V
+bpgs_70.tab      GL83.1           M8V
+bpgs_71.tab      GL65             M5V
+bpgs_72.tab      HR7567           B1IV
+bpgs_73.tab      HR7591           B2III
+bpgs_74.tab      20-AQL           B3IV
+bpgs_75.tab      HR7467           B3III
+bpgs_76.tab      IOTA-LYR         B7IV
+bpgs_77.tab      HR7346           B7III
+bpgs_78.tab      59-HER           A3III
+bpgs_79.tab      HR6642           A0IV
+bpgs_80.tab      11-SGE           B9IV
+bpgs_81.tab      60-HER           A3IV
+bpgs_82.tab      HD192285         A4IV
+bpgs_83.tab      ALPHA-OPH        A5III
+bpgs_84.tab      HD165475B        A5IV
+bpgs_85.tab      HD165475         A5IV
+bpgs_86.tab      XI-SER           F0IV
+bpgs_87.tab      HD5132           F0IV
+bpgs_88.tab      HD508            A9IV
+bpgs_89.tab      HD210875         F0IV
+bpgs_90.tab      RHO-CAP          F2IV
+bpgs_91.tab      HD7331           F7IV
+bpgs_92.tab      BD+63D0013       F5IV
+bpgs_93.tab      HD13391          G2IV
+bpgs_94.tab      HD154962         G8IV
+bpgs_95.tab      HD192344         G4IV
+bpgs_96.tab      HR6516           G6IV
+bpgs_97.tab      HR7670           G6IV
+bpgs_98.tab      HD128428         G3IV
+bpgs_99.tab      31-AQL           G8IV
+bpgs_100.tab     BD-02D4018       G5IV
+bpgs_101.tab     M67-F143?
+bpgs_102.tab     HD11004          G5IV
+bpgs_103.tab     HD173399A        G5IV
+bpgs_104.tab     HD56176          G7IV
+bpgs_105.tab     HD227693         G5IV
+bpgs_106.tab     HD199580         K2IV
+bpgs_107.tab     HD152306         G8III
+bpgs_108.tab     PRAESEPE-212
+bpgs_109.tab     THETA1-TAU       G8III
+bpgs_110.tab     HD170527         G5IV
+bpgs_111.tab     HD136366         K0III
+bpgs_112.tab     HD191615         G8IV
+bpgs_113.tab     HD124679         K0III
+bpgs_114.tab     HD131111         K0III
+bpgs_115.tab     HD113439         K0III
+bpgs_116.tab     HD4744           G8IV
+bpgs_117.tab     HD7010           K0IV
+bpgs_118.tab     46-LMI           K0III
+bpgs_119.tab     91-AQR           K0III
+bpgs_120.tab     M67-F141
+bpgs_121.tab     HR8924A          K3III
+bpgs_122.tab     HD140301         K0IV
+bpgs_123.tab     HD95272          K0III
+bpgs_124.tab     HD72184          K2III
+bpgs_125.tab     HD119425         K2III
+bpgs_126.tab     HD106760         K1III
+bpgs_127.tab     PSI-UMA          K1III
+bpgs_128.tab     PHI-SER          K1III
+bpgs_129.tab     HD136514         K3III
+bpgs_130.tab     MU-AQL           K3III
+bpgs_131.tab     HR5227           K2IIIP
+bpgs_132.tab     HD154759         K3III
+bpgs_133.tab     20-CYG           K3III
+bpgs_134.tab     ALPH-SER         K2III
+bpgs_135.tab     MU-LEO           K2III
+bpgs_136.tab     BD+01D3131       K0III
+bpgs_137.tab     M67-F170
+bpgs_138.tab     18-LIB-A         K2IIIP
+bpgs_139.tab     BD+28D2165       K1IV
+bpgs_140.tab     NGC188-1_69
+bpgs_141.tab     BD+30D2344       K3III
+bpgs_142.tab     HD83618          K3III
+bpgs_143.tab     HD158885         K3III
+bpgs_144.tab     HD166780         K5III
+bpgs_145.tab     HD148513         K4III
+bpgs_146.tab     M67-T626
+bpgs_147.tab     HD127227         K5III
+bpgs_148.tab     M67-IV-202
+bpgs_149.tab     HD50778          K4III
+bpgs_150.tab     HD62721          K5III
+bpgs_151.tab     HD116870         M0III
+bpgs_152.tab     HD60522          M0III
+bpgs_153.tab     BD-01D3113       K5III
+bpgs_154.tab     BD+02D2884       K5III
+bpgs_155.tab     BD-02D3873       M0III
+bpgs_156.tab     HD104216         M2III
+bpgs_157.tab     HD142804         M1III
+bpgs_158.tab     HD30959          M3III
+bpgs_159.tab     HD151658         M2III
+bpgs_160.tab     BD-02D4025       M2III
+bpgs_161.tab     BD-01D3097       M2III
+bpgs_162.tab     TX-DRA
+bpgs_163.tab     Z-CYG            M8III
+bpgs_164.tab     BD+01D3133       M5III
+bpgs_165.tab     BD-02D3886       M5III
+bpgs_166.tab     W-HER            M6III
+bpgs_167.tab     TY-DRA           M8
+bpgs_168.tab     SW-VIR           M7III
+bpgs_169.tab     RZ-HER           M6III
+bpgs_170.tab     R-LEO
+bpgs_171.tab     AW-CYG           N
+bpgs_172.tab     WZ-CAS           N
+bpgs_173.tab     69-CYG           B0IB
+bpgs_174.tab     HR7699           B5IB
+bpgs_175.tab     HR8020           B8IA
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_104.fits b/EXOSIMS/TargetList/bpgs/bpgs_104.fits
new file mode 100644
index 000000000..d7a6895b5
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_104.fits differ
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_106.fits b/EXOSIMS/TargetList/bpgs/bpgs_106.fits
new file mode 100644
index 000000000..44d51362d
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_106.fits differ
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_17.fits b/EXOSIMS/TargetList/bpgs/bpgs_17.fits
new file mode 100644
index 000000000..638251895
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_17.fits differ
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_31.fits b/EXOSIMS/TargetList/bpgs/bpgs_31.fits
new file mode 100644
index 000000000..5464e3e4e
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_31.fits differ
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_37.fits b/EXOSIMS/TargetList/bpgs/bpgs_37.fits
new file mode 100644
index 000000000..ae51112c8
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_37.fits differ
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_59.fits b/EXOSIMS/TargetList/bpgs/bpgs_59.fits
new file mode 100644
index 000000000..d3077bb9e
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_59.fits differ
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_6.fits b/EXOSIMS/TargetList/bpgs/bpgs_6.fits
new file mode 100644
index 000000000..0b431b7ab
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_6.fits differ
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_70.fits b/EXOSIMS/TargetList/bpgs/bpgs_70.fits
new file mode 100644
index 000000000..6b064472d
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_70.fits differ
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_72.fits b/EXOSIMS/TargetList/bpgs/bpgs_72.fits
new file mode 100644
index 000000000..546f669a0
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_72.fits differ
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_74.fits b/EXOSIMS/TargetList/bpgs/bpgs_74.fits
new file mode 100644
index 000000000..718c9fcbd
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_74.fits differ
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_76.fits b/EXOSIMS/TargetList/bpgs/bpgs_76.fits
new file mode 100644
index 000000000..6d2ffd082
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_76.fits differ
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_77.fits b/EXOSIMS/TargetList/bpgs/bpgs_77.fits
new file mode 100644
index 000000000..5c9b15db6
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_77.fits differ
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_80.fits b/EXOSIMS/TargetList/bpgs/bpgs_80.fits
new file mode 100644
index 000000000..2c9bf7999
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_80.fits differ
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_81.fits b/EXOSIMS/TargetList/bpgs/bpgs_81.fits
new file mode 100644
index 000000000..b38618ea3
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_81.fits differ
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_88.fits b/EXOSIMS/TargetList/bpgs/bpgs_88.fits
new file mode 100644
index 000000000..d279f183a
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_88.fits differ
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_9.fits b/EXOSIMS/TargetList/bpgs/bpgs_9.fits
new file mode 100644
index 000000000..a11c129f6
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_9.fits differ
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_91.fits b/EXOSIMS/TargetList/bpgs/bpgs_91.fits
new file mode 100644
index 000000000..78f6a340f
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_91.fits differ
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_95.fits b/EXOSIMS/TargetList/bpgs/bpgs_95.fits
new file mode 100644
index 000000000..4fae77baa
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_95.fits differ
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_97.fits b/EXOSIMS/TargetList/bpgs/bpgs_97.fits
new file mode 100644
index 000000000..d1cd95188
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_97.fits differ
diff --git a/EXOSIMS/TargetList/bpgs/bpgs_98.fits b/EXOSIMS/TargetList/bpgs/bpgs_98.fits
new file mode 100644
index 000000000..c95527460
Binary files /dev/null and b/EXOSIMS/TargetList/bpgs/bpgs_98.fits differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_105.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_105.fits
deleted file mode 100644
index 062b278d9..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_105.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_17.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_17.fits
deleted file mode 100644
index 9ebf4995d..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_17.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_19.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_19.fits
deleted file mode 100644
index 1b0d477bb..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_19.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_21.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_21.fits
deleted file mode 100644
index acaaab6de..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_21.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_22.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_22.fits
deleted file mode 100644
index c5f8f5562..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_22.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_24.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_24.fits
deleted file mode 100644
index d5d7274b0..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_24.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_25.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_25.fits
deleted file mode 100644
index bc0cb8e3b..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_25.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_28.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_28.fits
deleted file mode 100644
index f0d0355c1..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_28.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_29.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_29.fits
deleted file mode 100644
index 9ded03021..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_29.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_32.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_32.fits
deleted file mode 100644
index 284938983..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_32.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_41.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_41.fits
deleted file mode 100644
index 6dbeb6126..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_41.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_74.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_74.fits
deleted file mode 100644
index 651c88a6d..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_74.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_75.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_75.fits
deleted file mode 100644
index be5ada836..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_75.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_77.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_77.fits
deleted file mode 100644
index 33d62ed84..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_77.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_79.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_79.fits
deleted file mode 100644
index 588c3d337..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_79.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_80.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_80.fits
deleted file mode 100644
index 976212666..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_80.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_82.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_82.fits
deleted file mode 100644
index 987c256e0..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_82.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_83.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_83.fits
deleted file mode 100644
index 95bea567d..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_83.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_85.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_85.fits
deleted file mode 100644
index f5fe5c9c8..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_85.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_86.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_86.fits
deleted file mode 100644
index abad102ac..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_86.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_88.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_88.fits
deleted file mode 100644
index a68f59636..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_88.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_89.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_89.fits
deleted file mode 100644
index 244afc8a8..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_89.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_91.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_91.fits
deleted file mode 100644
index 84d3760fb..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_91.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_92.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_92.fits
deleted file mode 100644
index ea742c969..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_92.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/dat_uvk/pickles_uk_94.fits b/EXOSIMS/TargetList/dat_uvk/pickles_uk_94.fits
deleted file mode 100644
index 0cbc8a0bd..000000000
Binary files a/EXOSIMS/TargetList/dat_uvk/pickles_uk_94.fits and /dev/null differ
diff --git a/EXOSIMS/TargetList/spectral_catalog_index.json b/EXOSIMS/TargetList/spectral_catalog_index.json
new file mode 100644
index 000000000..69eca08d3
--- /dev/null
+++ b/EXOSIMS/TargetList/spectral_catalog_index.json
@@ -0,0 +1,1 @@
+{"O5V": {"file": "pickles_uk_1.fits", "specclass": ["O", 5, "V", 5]}, "O9V": {"file": "pickles_uk_2.fits", "specclass": ["O", 9, "V", 9]}, "B0V": {"file": "pickles_uk_3.fits", "specclass": ["B", 0, "V", 10]}, "B1V": {"file": "pickles_uk_4.fits", "specclass": ["B", 1, "V", 11]}, "B3V": {"file": "pickles_uk_5.fits", "specclass": ["B", 3, "V", 13]}, "B5V": {"file": "pickles_uk_6.fits", "specclass": ["B", 5, "V", 15]}, "B6V": {"file": "pickles_uk_6.fits", "specclass": ["B", 6, "V", 16]}, "B7V": {"file": "pickles_uk_6.fits", "specclass": ["B", 7, "V", 17]}, "B8V": {"file": "pickles_uk_7.fits", "specclass": ["B", 8, "V", 18]}, "B9V": {"file": "pickles_uk_8.fits", "specclass": ["B", 9, "V", 19]}, "A0V": {"file": "pickles_uk_9.fits", "specclass": ["A", 0, "V", 20]}, "A2V": {"file": "pickles_uk_10.fits", "specclass": ["A", 2, "V", 22]}, "A3V": {"file": "pickles_uk_11.fits", "specclass": ["A", 3, "V", 23]}, "A5V": {"file": "pickles_uk_12.fits", "specclass": ["A", 5, "V", 25]}, "A7V": {"file": "pickles_uk_13.fits", "specclass": ["A", 7, "V", 27]}, "F0V": {"file": "pickles_uk_14.fits", "specclass": ["F", 0, "V", 30]}, "F2V": {"file": "pickles_uk_15.fits", "specclass": ["F", 2, "V", 32]}, "F5V": {"file": "pickles_uk_16.fits", "specclass": ["F", 5, "V", 35]}, "F6V": {"file": "pickles_uk_18.fits", "specclass": ["F", 6, "V", 36]}, "F8V": {"file": "pickles_uk_20.fits", "specclass": ["F", 8, "V", 38]}, "G0V": {"file": "pickles_uk_23.fits", "specclass": ["G", 0, "V", 40]}, "G2V": {"file": "pickles_uk_26.fits", "specclass": ["G", 2, "V", 42]}, "G5V": {"file": "pickles_uk_27.fits", "specclass": ["G", 5, "V", 45]}, "G8V": {"file": "pickles_uk_30.fits", "specclass": ["G", 8, "V", 48]}, "K0V": {"file": "pickles_uk_31.fits", "specclass": ["K", 0, "V", 50]}, "K2V": {"file": "pickles_uk_33.fits", "specclass": ["K", 2, "V", 52]}, "K3V": {"file": "pickles_uk_34.fits", "specclass": ["K", 3, "V", 53]}, "K4V": {"file": "pickles_uk_35.fits", "specclass": ["K", 4, "V", 54]}, "K5V": {"file": "pickles_uk_36.fits", "specclass": ["K", 5, "V", 55]}, "K7V": {"file": "pickles_uk_37.fits", "specclass": ["K", 7, "V", 57]}, "M0V": {"file": "pickles_uk_38.fits", "specclass": ["M", 0, "V", 60]}, "M1V": {"file": "pickles_uk_39.fits", "specclass": ["M", 1, "V", 61]}, "M2V": {"file": "pickles_uk_40.fits", "specclass": ["M", 2, "V", 62]}, "M3V": {"file": "pickles_uk_42.fits", "specclass": ["M", 3, "V", 63]}, "M4V": {"file": "pickles_uk_43.fits", "specclass": ["M", 4, "V", 64]}, "M5V": {"file": "pickles_uk_44.fits", "specclass": ["M", 5, "V", 65]}, "M6V": {"file": "pickles_uk_45.fits", "specclass": ["M", 6, "V", 66]}, "B2IV": {"file": "pickles_uk_46.fits", "specclass": ["B", 2, "IV", 12]}, "B6IV": {"file": "pickles_uk_47.fits", "specclass": ["B", 6, "IV", 16]}, "A0IV": {"file": "pickles_uk_48.fits", "specclass": ["A", 0, "IV", 20]}, "A4IV": {"file": "pickles_uk_49.fits", "specclass": ["A", 4, "IV", 24]}, "A5IV": {"file": "pickles_uk_49.fits", "specclass": ["A", 5, "IV", 25]}, "A6IV": {"file": "pickles_uk_49.fits", "specclass": ["A", 6, "IV", 26]}, "A7IV": {"file": "pickles_uk_49.fits", "specclass": ["A", 7, "IV", 27]}, "F0IV": {"file": "pickles_uk_50.fits", "specclass": ["F", 0, "IV", 30]}, "F1IV": {"file": "pickles_uk_50.fits", "specclass": ["F", 1, "IV", 31]}, "F2IV": {"file": "pickles_uk_50.fits", "specclass": ["F", 2, "IV", 32]}, "F5IV": {"file": "pickles_uk_51.fits", "specclass": ["F", 5, "IV", 35]}, "F8IV": {"file": "pickles_uk_52.fits", "specclass": ["F", 8, "IV", 38]}, "G0IV": {"file": "pickles_uk_53.fits", "specclass": ["G", 0, "IV", 40]}, "G2IV": {"file": "pickles_uk_54.fits", "specclass": ["G", 2, "IV", 42]}, "G5IV": {"file": "pickles_uk_55.fits", "specclass": ["G", 5, "IV", 45]}, "G8IV": {"file": "pickles_uk_56.fits", "specclass": ["G", 8, "IV", 48]}, "K0IV": {"file": "pickles_uk_57.fits", "specclass": ["K", 0, "IV", 50]}, "K1IV": {"file": "pickles_uk_58.fits", "specclass": ["K", 1, "IV", 51]}, "K3IV": {"file": "pickles_uk_59.fits", "specclass": ["K", 3, "IV", 53]}, "O8III": {"file": "pickles_uk_60.fits", "specclass": ["O", 8, "III", 8]}, "B1III": {"file": "pickles_uk_61.fits", "specclass": ["B", 1, "III", 11]}, "B2III": {"file": "pickles_uk_61.fits", "specclass": ["B", 2, "III", 12]}, "B3III": {"file": "pickles_uk_62.fits", "specclass": ["B", 3, "III", 13]}, "B5III": {"file": "pickles_uk_63.fits", "specclass": ["B", 5, "III", 15]}, "B9III": {"file": "pickles_uk_64.fits", "specclass": ["B", 9, "III", 19]}, "A0III": {"file": "pickles_uk_65.fits", "specclass": ["A", 0, "III", 20]}, "A3III": {"file": "pickles_uk_66.fits", "specclass": ["A", 3, "III", 23]}, "A5III": {"file": "pickles_uk_67.fits", "specclass": ["A", 5, "III", 25]}, "A7III": {"file": "pickles_uk_68.fits", "specclass": ["A", 7, "III", 27]}, "F0III": {"file": "pickles_uk_69.fits", "specclass": ["F", 0, "III", 30]}, "F2III": {"file": "pickles_uk_70.fits", "specclass": ["F", 2, "III", 32]}, "F5III": {"file": "pickles_uk_71.fits", "specclass": ["F", 5, "III", 35]}, "G0III": {"file": "pickles_uk_72.fits", "specclass": ["G", 0, "III", 40]}, "G5III": {"file": "pickles_uk_73.fits", "specclass": ["G", 5, "III", 45]}, "G8III": {"file": "pickles_uk_76.fits", "specclass": ["G", 8, "III", 48]}, "K0III": {"file": "pickles_uk_78.fits", "specclass": ["K", 0, "III", 50]}, "K1III": {"file": "pickles_uk_81.fits", "specclass": ["K", 1, "III", 51]}, "K2III": {"file": "pickles_uk_84.fits", "specclass": ["K", 2, "III", 52]}, "K3III": {"file": "pickles_uk_87.fits", "specclass": ["K", 3, "III", 53]}, "K4III": {"file": "pickles_uk_90.fits", "specclass": ["K", 4, "III", 54]}, "K5III": {"file": "pickles_uk_93.fits", "specclass": ["K", 5, "III", 55]}, "M0III": {"file": "pickles_uk_95.fits", "specclass": ["M", 0, "III", 60]}, "M1III": {"file": "pickles_uk_96.fits", "specclass": ["M", 1, "III", 61]}, "M2III": {"file": "pickles_uk_97.fits", "specclass": ["M", 2, "III", 62]}, "M3III": {"file": "pickles_uk_98.fits", "specclass": ["M", 3, "III", 63]}, "M4III": {"file": "pickles_uk_99.fits", "specclass": ["M", 4, "III", 64]}, "M5III": {"file": "pickles_uk_100.fits", "specclass": ["M", 5, "III", 65]}, "M6III": {"file": "pickles_uk_101.fits", "specclass": ["M", 6, "III", 66]}, "M7III": {"file": "pickles_uk_102.fits", "specclass": ["M", 7, "III", 67]}, "M8III": {"file": "pickles_uk_103.fits", "specclass": ["M", 8, "III", 68]}, "M9III": {"file": "pickles_uk_104.fits", "specclass": ["M", 9, "III", 69]}, "B2II": {"file": "pickles_uk_106.fits", "specclass": ["B", 2, "II", 12]}, "B5II": {"file": "pickles_uk_107.fits", "specclass": ["B", 5, "II", 15]}, "F0II": {"file": "pickles_uk_108.fits", "specclass": ["F", 0, "II", 30]}, "F2II": {"file": "pickles_uk_109.fits", "specclass": ["F", 2, "II", 32]}, "G5II": {"file": "pickles_uk_110.fits", "specclass": ["G", 5, "II", 45]}, "K0II": {"file": "pickles_uk_111.fits", "specclass": ["K", 0, "II", 50]}, "K1II": {"file": "pickles_uk_111.fits", "specclass": ["K", 1, "II", 51]}, "K3II": {"file": "pickles_uk_112.fits", "specclass": ["K", 3, "II", 53]}, "K4II": {"file": "pickles_uk_112.fits", "specclass": ["K", 4, "II", 54]}, "M3II": {"file": "pickles_uk_113.fits", "specclass": ["M", 3, "II", 63]}, "B0I": {"file": "pickles_uk_114.fits", "specclass": ["B", 0, "I", 10]}, "B1I": {"file": "pickles_uk_115.fits", "specclass": ["B", 1, "I", 11]}, "B3I": {"file": "pickles_uk_116.fits", "specclass": ["B", 3, "I", 13]}, "B5I": {"file": "pickles_uk_117.fits", "specclass": ["B", 5, "I", 15]}, "B8I": {"file": "pickles_uk_118.fits", "specclass": ["B", 8, "I", 18]}, "A0I": {"file": "pickles_uk_119.fits", "specclass": ["A", 0, "I", 20]}, "A2I": {"file": "pickles_uk_120.fits", "specclass": ["A", 2, "I", 22]}, "F0I": {"file": "pickles_uk_121.fits", "specclass": ["F", 0, "I", 30]}, "F5I": {"file": "pickles_uk_122.fits", "specclass": ["F", 5, "I", 35]}, "F8I": {"file": "pickles_uk_123.fits", "specclass": ["F", 8, "I", 38]}, "G0I": {"file": "pickles_uk_124.fits", "specclass": ["G", 0, "I", 40]}, "G2I": {"file": "pickles_uk_125.fits", "specclass": ["G", 2, "I", 42]}, "G5I": {"file": "pickles_uk_126.fits", "specclass": ["G", 5, "I", 45]}, "G8I": {"file": "pickles_uk_127.fits", "specclass": ["G", 8, "I", 48]}, "K2I": {"file": "pickles_uk_128.fits", "specclass": ["K", 2, "I", 52]}, "K3I": {"file": "pickles_uk_129.fits", "specclass": ["K", 3, "I", 53]}, "K4I": {"file": "pickles_uk_130.fits", "specclass": ["K", 4, "I", 54]}, "M2I": {"file": "pickles_uk_131.fits", "specclass": ["M", 2, "I", 62]}, "G6IV": {"file": "bpgs_97.fits", "specclass": ["G", 6, "IV", 46]}, "B2V": {"file": "bpgs_6.fits", "specclass": ["B", 2, "V", 12]}, "B7IV": {"file": "bpgs_76.fits", "specclass": ["B", 7, "IV", 17]}, "B7III": {"file": "bpgs_77.fits", "specclass": ["B", 7, "III", 17]}, "A3IV": {"file": "bpgs_81.fits", "specclass": ["A", 3, "IV", 23]}, "F4V": {"file": "bpgs_31.fits", "specclass": ["F", 4, "V", 34]}, "B3IV": {"file": "bpgs_74.fits", "specclass": ["B", 3, "IV", 13]}, "F9V": {"file": "bpgs_37.fits", "specclass": ["F", 9, "V", 39]}, "G7IV": {"file": "bpgs_104.fits", "specclass": ["G", 7, "IV", 47]}, "A1V": {"file": "bpgs_17.fits", "specclass": ["A", 1, "V", 21]}, "F7IV": {"file": "bpgs_91.fits", "specclass": ["F", 7, "IV", 37]}, "B4V": {"file": "bpgs_9.fits", "specclass": ["B", 4, "V", 14]}, "B9IV": {"file": "bpgs_80.fits", "specclass": ["B", 9, "IV", 19]}, "M8V": {"file": "bpgs_70.fits", "specclass": ["M", 8, "V", 68]}, "G4IV": {"file": "bpgs_95.fits", "specclass": ["G", 4, "IV", 44]}, "G3IV": {"file": "bpgs_98.fits", "specclass": ["G", 3, "IV", 43]}, "K8V": {"file": "bpgs_59.fits", "specclass": ["K", 8, "V", 58]}, "K2IV": {"file": "bpgs_106.fits", "specclass": ["K", 2, "IV", 52]}, "A9IV": {"file": "bpgs_88.fits", "specclass": ["A", 9, "IV", 29]}, "B1IV": {"file": "bpgs_72.fits", "specclass": ["B", 1, "IV", 11]}}
\ No newline at end of file
diff --git a/EXOSIMS/ZodiacalLight/Mennesson.py b/EXOSIMS/ZodiacalLight/Mennesson.py
index 94bfc2871..75a1e717c 100644
--- a/EXOSIMS/ZodiacalLight/Mennesson.py
+++ b/EXOSIMS/ZodiacalLight/Mennesson.py
@@ -27,56 +27,6 @@ def __init__(self, EZ_distribution="nominal_maxL_distribution.fits", **specs):
             )
         self.fitsdata = fits.open(self.EZ_distribution)[0].data
 
-    def fEZ(self, MV, I, d):
-        """Returns surface brightness of exo-zodiacal light
-
-        Args:
-            MV (integer ndarray):
-                Apparent magnitude of the star (in the V band)
-            I (astropy Quantity array):
-                Inclination of the planets of interest in units of deg
-            d (astropy Quantity nx3 array):
-                Distance to star of the planets of interest in units of AU
-
-        Returns:
-            fEZ (astropy Quantity array):
-                Surface brightness of exo-zodiacal light in units of 1/arcsec2
-
-        """
-
-        # apparent magnitude of the star (in the V band)
-        MV = np.array(MV, ndmin=1, copy=False)
-        # apparent magnitude of the Sun (in the V band)
-        MVsun = 4.83
-
-        if self.commonSystemfEZ:
-            nEZ = self.nEZ
-        else:
-            nEZ = self.gen_systemnEZ(len(MV))
-
-        # supplementary angle for inclination > 90 degrees
-        beta = I.to("deg").value
-        mask = np.where(beta > 90)[0]
-        beta[mask] = 180.0 - beta[mask]
-        beta = 90.0 - beta
-
-        fbeta = 2.44 - 0.0403 * beta + 0.000269 * beta**2
-        fbeta = (
-            fbeta / 1.473
-        )  # 1.473 is adjustment for inputs being for 60 deg. inclination
-
-        fEZ = (
-            nEZ
-            * 10 ** (-0.4 * self.magEZ)
-            * 10.0 ** (-0.4 * (MV - MVsun))
-            * 2
-            * fbeta
-            / d.to("AU").value ** 2
-            / u.arcsec**2
-        )
-
-        return fEZ
-
     def gen_systemnEZ(self, nStars):
         """Ranomly generates the number of Exo-Zodi
         Args:
@@ -87,5 +37,50 @@ def gen_systemnEZ(self, nStars):
                 numpy array of exo-zodi randomly selected from fitsdata
         """
         nEZ_seed = np.random.randint(len(self.fitsdata) - nStars)
-        nEZ = self.fitsdata[nEZ_seed : (nEZ_seed + nStars)]
+        nEZ = 2 * self.fitsdata[nEZ_seed : (nEZ_seed + nStars)]
         return nEZ
+
+    def zodi_latitudinal_correction_factor(self, theta, model=None, interp_at=135):
+        """
+        Compute zodiacal light latitudinal correction factor.  This is a multiplicative
+        factor to apply to zodiacal light intensity to account for the orientation of
+        the dust disk with respect to the observer.
+
+        Args:
+            theta (astropy.units.Quantity):
+                Angle of disk. For local zodi, this is equivalent to the absolute value
+                of the ecliptic latitude of the look vector. For exozodi, this is 90
+                degrees minus the inclination of the orbital plane.
+            model (str, optional):
+                Model to use.  Options are Lindler2006, Stark2014, or interp
+                (case insensitive). See :ref:`zodiandexozodi` for details.
+                Defaults to None
+            interp_at (float):
+                If ``model`` is 'interp', interpolate Leinert Table 17 at this
+                longitude. Defaults to 135.
+
+        Returns:
+            float or numpy.ndarray:
+                Correction factor of zodiacal light at requested angles.
+                Has same dimension as input.
+
+        .. note::
+
+            Unlike the color correction factor, this quantity is wavelength independent
+            and thus does not change if using power or photon units.
+
+        .. note::
+
+            The systems in the data file are all at 60 degrees inclination, so we scale
+            by the 90-60=30 degree value of the correction factor.
+
+        """
+
+        fbeta = super().zodi_latitudinal_correction_factor(
+            theta, model=model, interp_at=interp_at
+        )
+        fbeta30 = super().zodi_latitudinal_correction_factor(
+            30 * u.deg, model=model, interp_at=interp_at
+        )
+
+        return fbeta / fbeta30
diff --git a/EXOSIMS/ZodiacalLight/Stark.py b/EXOSIMS/ZodiacalLight/Stark.py
index 0901ddc65..33e1eb3f4 100644
--- a/EXOSIMS/ZodiacalLight/Stark.py
+++ b/EXOSIMS/ZodiacalLight/Stark.py
@@ -3,11 +3,10 @@
 import numpy as np
 import os
 import astropy.units as u
-import astropy.constants as const
 from astropy.coordinates import SkyCoord
-from scipy.interpolate import griddata
 import pickle
 from astropy.time import Time
+from synphot import units
 import sys
 
 
@@ -23,16 +22,8 @@ class Stark(ZodiacalLight):
     def __init__(self, magZ=23.0, magEZ=22.0, varEZ=0.0, **specs):
         """ """
         ZodiacalLight.__init__(self, magZ, magEZ, varEZ, **specs)
-        (
-            self.points,
-            self.values,
-        ) = (
-            self.calcfbetaInput()
-        )  # looking at certain lat/long rel to antisolar point, create interpolation
-        # grid. in old version, do this for a certain value
-        # Here we calculate the Zodiacal Light Model
 
-        self.global_min = np.min(self.values)
+        self.global_min = np.min(self.zodi_values)
 
     def fZ(self, Obs, TL, sInds, currentTimeAbs, mode):
         """Returns surface brightness of local zodiacal light
@@ -98,25 +89,19 @@ def fZ(self, Obs, TL, sInds, currentTimeAbs, mode):
         lat = abs(lat)
         # technically, latitude is physically capable of being >90 deg
 
-        # Interpolates 2D
-        fbeta = griddata(self.points, self.values, list(zip(lon, lat)))
+        # First get intensities at reference values
+        Izod = self.zodi_intensity_at_location(lon * u.deg, lat * u.deg)
+        # Now correct for color
+        Izod *= self.zodi_color_correction_factor(mode["lam"])
 
-        lam = mode["lam"]  # extract wavelength
-        BW = mode["BW"]  # extract bandwidth
-
-        f = (
-            10.0 ** (self.logf(np.log10(lam.to("um").value)))
-            * u.W
-            / u.m**2
-            / u.sr
-            / u.um
+        # convert to photon units
+        Izod_photons = (
+            units.convert_flux(mode["lam"], Izod * u.sr, units.PHOTLAM) / u.sr
         )
-        h = const.h  # Planck constant
-        c = const.c  # speed of light in vacuum
-        ephoton = h * c / lam / u.ph  # energy of a photon
-        F0 = TL.F0(BW, lam)  # zero-magnitude star (sun) (in ph/s/m2/nm)
-        f_corr = f / ephoton / F0  # color correction factor
-        fZ = fbeta * f_corr.to("1/arcsec2")
+
+        # finally, scale by mode's zero mag flux
+        fZ = (Izod_photons / (mode["F0"] / mode["deltaLam"])).to("1/arcsec2")
+
         return fZ
 
     def calcfZmax(self, sInds, Obs, TL, TK, mode, hashname, koTimes=None):
@@ -326,29 +311,14 @@ def global_zodi_min(self, mode):
                 in (1/arcsec**2)
         """
 
-        lam = mode["lam"]
+        fZminglobal = self.global_min * self.zodi_color_correction_factor(mode["lam"])
 
-        f = (
-            10.0 ** (self.logf(np.log10(lam.to("um").value)))
-            * u.W
-            / u.m**2
-            / u.sr
-            / u.um
+        # convert to photon units
+        fZminglobal = (
+            units.convert_flux(mode["lam"], fZminglobal * u.sr, units.PHOTLAM) / u.sr
         )
-        h = const.h
-        c = const.c
-
-        # energy of a photon
-        ephoton = h * c / lam / u.ph
-
-        # zero-magnitude star (sun) (in ph/s/m2/nm)
-        F0 = (
-            1e4 * 10 ** (4.01 - (lam / u.nm - 550) / 770) * u.ph / u.s / u.m**2 / u.nm
-        )
-
-        # color correction factor
-        f_corr = f / ephoton / F0
 
-        fZminglobal = self.global_min * f_corr.to("1/arcsec2")
+        # finally, scale by mode's zero mag flux
+        fZminglobal = (fZminglobal / (mode["F0"] / mode["deltaLam"])).to("1/arcsec2")
 
         return fZminglobal
diff --git a/EXOSIMS/util/get_module.py b/EXOSIMS/util/get_module.py
index a0784073d..4865b8119 100644
--- a/EXOSIMS/util/get_module.py
+++ b/EXOSIMS/util/get_module.py
@@ -188,7 +188,7 @@ def shorten_name(filename):
 #########################################
 
 
-def get_module(name, folder=None):
+def get_module(name, folder=None, silent=False):
     """Import specific or Prototype class module.
 
     There are three ways to use the name argument:
@@ -221,6 +221,8 @@ class within the given file.  This supports locally-customized module sets.
             validated against the _modtype attribute of the loaded class.
             For specific modules, this is the same as the name of the
             folder housing the desired class.
+        silent (bool):
+            Set True to suppress printing import information. Defaults False.
 
     Returns:
         desired_module (object):
@@ -255,7 +257,9 @@ class within the given file.  This supports locally-customized module sets.
             raise ValueError('Could not import module "%s" (path issue?).' % name)
         source = full_module.__name__
         note = (
-            "prototype" if "EXOSIMS.Prototypes" in full_module.__name__ else "specific"
+            "prototype"
+            if "EXOSIMS.Prototypes" in full_module.__name__
+            else "implementation"
         )
 
     # extract the tail end of the module name -- e.g., TimeKeeping, or Nemati
@@ -274,7 +278,8 @@ class within the given file.  This supports locally-customized module sets.
     assert inspect.isclass(desired_module), (
         "Module contains an attribute %s but it is not a class." % module_name
     )
-    print("Imported %s (%s module) from %s" % (module_name, note, shorten_name(source)))
+    if not (silent):
+        print("Imported %s (%s) from %s" % (module_name, note, shorten_name(source)))
     # validate the _modtype property of the module we just loaded
     assert hasattr(
         desired_module, "_modtype"
diff --git a/EXOSIMS/util/input_script_check.py b/EXOSIMS/util/input_script_check.py
new file mode 100644
index 000000000..3024a5e86
--- /dev/null
+++ b/EXOSIMS/util/input_script_check.py
@@ -0,0 +1,109 @@
+from EXOSIMS.util.get_module import get_module
+import EXOSIMS.MissionSim
+import json
+from typing import List, Dict, Union, Any, Tuple
+from EXOSIMS.util.keyword_fun import get_all_mod_kws, check_opticalsystem_kws
+import argparse
+import copy
+
+
+def parse_mods(specs: Dict[str, Any]) -> Dict[str, type]:
+    """Check for presence of all required modules in input specs and return list of
+    module class types.
+
+    Args:
+        specs (str or dict):
+            Either full path to JSON script or an :ref:`sec:inputspec` dict
+
+    Returns:
+        dict:
+            dict of all module classes along with MissionSim
+    """
+
+    req_mods = [
+        "StarCatalog",
+        "PlanetPopulation",
+        "PlanetPhysicalModel",
+        "OpticalSystem",
+        "ZodiacalLight",
+        "BackgroundSources",
+        "PostProcessing",
+        "Completeness",
+        "TargetList",
+        "SimulatedUniverse",
+        "Observatory",
+        "TimeKeeping",
+        "SurveySimulation",
+        "SurveyEnsemble",
+    ]
+
+    assert "modules" in specs, "Input specs missing modules keyword."
+    mods = {}
+    for k in req_mods:
+        assert k in specs["modules"], f"Module {k} missing from input specs."
+        mods[k] = get_module(specs["modules"][k], k, silent=True)
+
+    mods["MissionSim"] = EXOSIMS.MissionSim.MissionSim
+
+    return mods
+
+
+def check_for_unused_kws(
+    specs: Union[Dict[str, Any], str]
+) -> Tuple[List[str], Dict[str, Any]]:
+    """Check input specification for consistency with module inputs
+
+    Args:
+        specs (str or dict):
+            Either full path to JSON script or an :ref:`sec:inputspec` dict
+
+    Returns:
+        tuple:
+            unused (list):
+                List of unused keywords
+            specs (dict):
+                Original input (useful if read from disk)
+    """
+
+    if isinstance(specs, str):
+        with open(specs, "r") as f:
+            specs = json.loads(f.read())
+
+    mods = parse_mods(specs)
+    allkws, allkwmods, ukws, ukwcounts = get_all_mod_kws(mods)
+
+    unused = list(set(specs.keys()) - set(ukws))
+    if "modules" in unused:
+        unused.remove("modules")
+
+    return unused, specs
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(
+        description="Check an input script for spurious entries."
+    )
+    parser.add_argument("path", nargs=1, type=str, help="Input script path (str).")
+    args = parser.parse_args()
+
+    # look for unused kws
+    unused, specs = check_for_unused_kws(args.path[0])
+    if len(unused) > 0:
+        print(
+            "\nThe following input keywords were not used in any "
+            "module init:\n\t{}".format("\n\t".join(unused))
+        )
+
+    # now check the optical system
+    try:
+        OS = get_module(
+            specs["modules"]["OpticalSystem"], "OpticalSystem", silent=True
+        )(**copy.deepcopy(specs))
+        out = check_opticalsystem_kws(specs, OS)
+        if out != "":
+            print(f"\n{out}")
+    except:  # noqa: E722
+        print(
+            "Could not instantiate OpticalSystem with this script, "
+            "likely due to missing files."
+        )
diff --git a/EXOSIMS/util/keyword_fun.py b/EXOSIMS/util/keyword_fun.py
index 2595f516c..6382f4684 100644
--- a/EXOSIMS/util/keyword_fun.py
+++ b/EXOSIMS/util/keyword_fun.py
@@ -1,6 +1,7 @@
 import inspect
-from typing import List, Dict
+from typing import List, Dict, Tuple, Any
 import re
+import numpy as np
 
 
 def get_all_args(mod: type) -> List[str]:
@@ -25,6 +26,44 @@ def get_all_args(mod: type) -> List[str]:
     return kws
 
 
+def get_all_mod_kws(mods: Dict[str, type]) -> Tuple[List[str], List[str]]:
+    """Collect all keywords from all modules
+
+    Args:
+        mods (dict):
+            dict of all module classes along with MissionSim
+
+    Returns:
+        tuple:
+            allkws (list):
+                All keywords
+            allkwmods (list):
+                The names of the modules the keywords belong to.
+            ukws (~numpy.ndarray(str)):
+                Unique keywords (excluding self and scriptfile)
+            ukwcounts (~numpy.ndarray(int)):
+                Unique keyword counts
+
+    """
+
+    # collect keywords
+    allkws = []
+    allkwmods = []
+    for modname in mods:
+        tmp = get_all_args(mods[modname])
+        allkws += tmp
+        allkwmods += [mods[modname].__name__] * len(tmp)
+
+    # pop things in whitelist and get unique args
+    ukws = np.array(allkws)
+    whitelist = ["self", "scriptfile"]
+    for w in whitelist:
+        ukws = ukws[ukws != w]
+    ukws, ukwcounts = np.unique(ukws, return_counts=True)
+
+    return allkws, allkwmods, ukws, ukwcounts
+
+
 def get_allmod_args(sim) -> Dict[str, List[str]]:
     """Return list of all arguments to all inits of all modules in a MissionSim Object
 
@@ -63,3 +102,58 @@ def get_allmod_args(sim) -> Dict[str, List[str]]:
                 ]
 
     return allkws
+
+
+def check_opticalsystem_kws(specs: Dict[str, Any], OS: Any):
+    """Check input specification against an optical system object
+
+    Args:
+        specs (str or dict):
+            Either full path to JSON script or an :ref:`sec:inputspec` dict
+        OS (:ref:`OpticalSystem`):
+            OpticalSystem object
+
+    Returns:
+        str:
+            Description of what's wrong with the input (blank if its all good).
+
+    """
+    out = ""
+
+    if "scienceInstruments" in specs:
+        for inst in specs["scienceInstruments"]:
+            extra_keys = list(set(inst.keys()) - set(OS.allowed_scienceInstrument_kws))
+            if len(extra_keys) > 0:
+                out += (
+                    f"Unknown key(s): {', '.join(extra_keys)} for "
+                    f"science instrument {inst['name']}\n"
+                )
+
+    if "starlightSuppressionSystems" in specs:
+        for syst in specs["starlightSuppressionSystems"]:
+            extra_keys = list(
+                set(syst.keys()) - set(OS.allowed_starlightSuppressionSystem_kws)
+            )
+            if len(extra_keys) > 0:
+                out += (
+                    f"Unknown key(s): {', '.join(extra_keys)} for "
+                    f"system {syst['name']}\n"
+                )
+            if ("core_mean_intensity" in syst) and ("core_platescale" not in syst):
+                out += (
+                    f"core_mean_intensity requires core_platescale to be set in "
+                    f"system {syst['name']}\n"
+                )
+
+    if "observingModes" in specs:
+        for nmode, mode in enumerate(specs["observingModes"]):
+            extra_keys = list(set(mode.keys()) - set(OS.allowed_observingMode_kws))
+            if "description" in extra_keys:
+                extra_keys.remove("description")
+            if len(extra_keys) > 0:
+                out += (
+                    f"Unknown key(s): {', '.join(extra_keys)} for "
+                    f"observing mode #{nmode}\n"
+                )
+
+    return out
diff --git a/EXOSIMS/util/photometricModels.py b/EXOSIMS/util/photometricModels.py
new file mode 100644
index 000000000..15e99c510
--- /dev/null
+++ b/EXOSIMS/util/photometricModels.py
@@ -0,0 +1,141 @@
+"""
+Various useful photometric models from the literature
+"""
+import astropy.units as u
+from synphot.models import Box1D as Box1D_orig
+from synphot.compat import ASTROPY_LT_5_0
+from functools import partial
+import numpy as np
+import warnings
+
+
+def TraubZeroMagFluxDensity(lam):
+    """
+    Zero-magnitude spectral flux density at a given wavelength
+
+    This implements the model from [Traub2016]_ which has a stated valid range between
+    0.4 and 1 um.
+
+    Args:
+        lam (astropy.units.Quantity):
+            Wavelength at which to evaluate the flux.
+
+    Returns:
+        ~astropy.units.Quantity:
+            Zero magnitude flux desnsity at wavelength lam. This will have the same
+            dimensionality as the input. Default units are ph/s/cm^2/nm
+
+    """
+
+    a = 4.01 - (lam.to(u.um).value - 0.55) / 0.770
+    f = (10**a) * (u.ph / u.s / u.cm**2 / u.nm)
+
+    return f
+
+
+def TraubApparentMagnitude(V, BV, lam):
+    """
+    Star apparent magnitude at a given wavelength
+
+    This implements the model from [Traub2016]_ which has a stated valid range between
+    0.4 and 1 um.
+
+    Args:
+        V (float or ~numpy.ndarray(float)):
+            Johnson V-band magnitude(s)
+        BV (float or ~numpy.ndarray(float)):
+            B-V color.  Must be same type and dimensionality as ``V``
+        lam (astropy.units.Quantity):
+            Wavelength at which to evaluate the flux.  Must be scalar.
+
+    Returns:
+        ~numpy.ndarray(float):
+            Apparent magnitude at wavelength lam. This will have the same
+            dimensionality as the V and BV inputs.
+    """
+
+    b = 2.20 if lam < 0.55 * u.um else 1.54
+    m = V + b * BV * (1 / lam.to(u.um).value - 1.818)
+
+    return m
+
+
+def TraubStellarFluxDensity(V, BV, lam):
+    """
+    Stellar spectral flux density at a given wavelength.
+
+    This implements the model from [Traub2016]_ which has a stated valid range between
+    0.4 and 1 um.
+
+    .. warning::
+
+        Values will be returned for wavelengths outside the valid range, but a warning
+        will be generated.
+
+    Args:
+        V (float or ~numpy.ndarray(float)):
+            Johnson V-band magnitude(s)
+        BV (float or ~numpy.ndarray(float)):
+            B-V color.  Must be same type and dimensionality as ``V``
+        lam (astropy.units.Quantity):
+            Wavelength at which to evaluate the flux.  Must be scalar.
+
+    Returns:
+        ~astropy.units.Quantity:
+            Stellar flux desnsity at wavelength lam. This will have the same
+            dimensionality as the V and BV inputs. Default units are ph/s/cm^2/nm
+    """
+
+    if (lam < 0.4 * u.um) or (lam > 1 * u.um):
+        warnings.warn(
+            (
+                "Traub et al. 2016 models are only valid for wavelengths between "
+                "0.4 and 1 um"
+            )
+        )
+
+    F0 = TraubZeroMagFluxDensity(lam)
+    m = TraubApparentMagnitude(V, BV, lam)
+
+    f = F0 * 10 ** (-0.4 * m)
+
+    return f
+
+
+class Box1D(Box1D_orig):
+    """Same as `synphot.models.Box1D`, except with ``step`` input."""
+
+    def __init__(self, step=0.01, *args, **kwargs):
+
+        super(Box1D, self).__init__(*args, **kwargs)
+        self.step = float(step)
+
+    def sampleset(self, step=None, minimal=False):
+        """Return ``x`` array that samples the feature.
+
+        Parameters
+        ----------
+        step : float
+            Distance of first and last points w.r.t. bounding box.
+
+        minimal : bool
+            Only return the minimal points needed to define the box;
+            i.e., box edges and a point outside on each side.
+
+        """
+        if step is None:
+            step = self.step
+
+        if ASTROPY_LT_5_0:
+            w1, w2 = self.bounding_box
+        else:
+            w1, w2 = tuple(self.bounding_box.bounding_box())
+
+        if self._n_models == 1:
+            w = self._calc_sampleset(w1, w2, step, minimal)
+        else:
+            w = list(
+                map(partial(self._calc_sampleset, step=step, minimal=minimal), w1, w2)
+            )
+
+        return np.asarray(w)
diff --git a/EXOSIMS/util/planet_star_separation.py b/EXOSIMS/util/planet_star_separation.py
index 675e52610..d7aea55f5 100644
--- a/EXOSIMS/util/planet_star_separation.py
+++ b/EXOSIMS/util/planet_star_separation.py
@@ -8,23 +8,25 @@
 
 def planet_star_separation(a, e, v, w, i):
     """Following directly from Keithly 2021. Calculates planet star separation given KOE
+
     Args:
-        a (numpy array):
+        a (numpy.ndarray(float)):
             planet semi-major axis in AU
-        e (numpy array):
+        e (numpy.ndarray(float)):
             planet eccentricity
-        v (numpy array):
+        v (numpy.ndarray(float)):
             planet true anomaly rad
-        w (numpy array):
+        w (numpy.ndarray(float)):
             planet argument of periapsis rad
-        i (numpy array):
+        i (numpy.ndarray(float)):
             planet inclination rad
+
     Returns:
-        s (numpy array):
+        numpy.ndarray(float):
             planet-star separations in AU
     """
+
     r = a * (1.0 - e**2.0) / (1.0 + e * np.cos(v))
-    s = r * np.sqrt(
-        np.sin(v + w) ** 2.0 * np.cos(i) ** 2 + np.cos(v + w) ** 2.0
-    )  # this is from threeDellipseToTwoDellipse.ipynb
+    s = r * np.sqrt(np.sin(v + w) ** 2.0 * np.cos(i) ** 2 + np.cos(v + w) ** 2.0)
+
     return s
diff --git a/EXOSIMS/util/process_opticalsys_package.py b/EXOSIMS/util/process_opticalsys_package.py
new file mode 100644
index 000000000..9dd88474a
--- /dev/null
+++ b/EXOSIMS/util/process_opticalsys_package.py
@@ -0,0 +1,418 @@
+from astropy.io import fits
+import os
+from EXOSIMS.util.radialfun import (
+    radial_average,
+    genwindow,
+    resample_image,
+    circ_aperture,
+    com,
+    fitgaussian,
+)
+from scipy.ndimage import shift
+import numpy as np
+import warnings
+import json
+import astropy.units as u
+
+
+def process_opticalsys_package(
+    basepath,
+    stellar_intensity_file="stellar_intens.fits",
+    stellar_intensity_diameter_list_file="stellar_intens_diam_list.fits",
+    offax_psf_file="offax_psf.fits",
+    offax_psf_offset_list_file="offax_psf_offset_list.fits",
+    sky_trans_file="sky_trans.fits",
+    resamp=4,
+    outpath=None,
+    outname="",
+    name=None,
+    phot_aperture_radius=np.sqrt(2) / 2,
+    fit_gaussian=False,
+    use_phot_aperture_as_min=False,
+    overwrite=True,
+):
+    """Process optical system package defined by Stark & Krist to EXOSIMS
+    standard inputs.
+
+    Args:
+        basepath (str):
+            Full path to directory with all input files.
+        stellar_intensity_file (str, optional):
+            Filename of stellar intensity PSFs. Defaults to "stellar_intens.fits".
+            If None, no output generated.
+        stellar_intensity_diameter_list_file (str, optional):
+            Filename of stellar diameters corresponding to stellar_intensity_file.
+            Defaults to "stellar_intens_diam_list.fits". If None, no output generated.
+        offax_psf_file (str):
+            Filename of off-axis PSFs. Defaults to "offax_psf.fits".
+            If None, no output generated.
+        offax_psf_offset_list_file (str, optional):
+            Filename of off-axis PSF astrophysical offsets corresponding to
+            offax_psf_file. Defaults to "offax_psf_offset_list.fits".
+            If None, no output generated.
+        sky_trans_file (str, optional):
+            Filename of sky transmission map.  Defualts to "sky_trans.fits"
+            If None, no output generated.
+        resamp (float):
+            Resampling factor for PSFs.  Defaults to 4.
+        outpath (str, optional):
+            Full path to directory to write results. If None, use basepath.
+            Defaults None
+        outname (str):
+            Prefix for all output files.  If "" (default) then no prefix is added,
+            otherwise all files will be named outname_(descriptor).fits.
+        name (str, optional):
+            System name (for dictionary output). If None and outname is not "" then
+            use outname.  If None and outname is "" then write None.  Defaults None.
+        phot_aperture_radius (float):
+            Photometric aperture radius in native unit of input files (nominally
+            :math:`\\lambda/D`). Defaults to :math:`\\sqrt{2}/2`.  If fit_gaussian is
+            True and use_phot_aperture_as_min is True then this value replaces any fit
+            area that is smaller than the area of this value.
+        fit_gaussian (bool):
+            Fit 2D Gaussians to off-axis PSFs to compute throughput and core area.
+            Default False, in which case the core_area is always the value computed from
+            phot_aperture_radius.
+        use_phot_aperture_as_min (bool):
+            Only used if fit_gaussian is True.  If True, any coputed core area values
+            that are smaller than the area of the phot_aperture_radius are replaced with
+            that value. Defaults False
+        overwrite (bool):
+            Overwrite output files if they exist. Defaults True. If False, throws error.
+
+    Returns:
+        dict:
+            :ref:`StarlightSuppressionSystem` dictionary describing the generated
+            system
+
+    .. note::
+
+        The default expectation is that all 5 input files will be provided and
+        processed. However, any of these can be set to None, in which case processing
+        will be skipped for that filetype.  Paired files (i.e., ``intens`` and
+        ``intens_diam`` will be skipped if either is None).
+
+    """
+    if outpath is None:
+        outpath = basepath
+    if name is None and outname != "":
+        name = outname
+    if outname != "":
+        outname += "_"
+
+    # package inputs
+    ftypes = ["intens", "intens_diam", "offax_psf", "offax_psf_offset", "sky_trans"]
+    fnames = [
+        stellar_intensity_file,
+        stellar_intensity_diameter_list_file,
+        offax_psf_file,
+        offax_psf_offset_list_file,
+        sky_trans_file,
+    ]
+
+    headers = {}
+    data = {}
+    for ftype, fname in zip(ftypes, fnames):
+        if fname is not None:
+            with fits.open(os.path.join(basepath, fname)) as hdulist:
+                data[ftype] = hdulist[0].data
+                headers[ftype] = hdulist[0].header
+        else:
+            data[ftype] = None
+            headers[ftype] = None
+
+    # keep track of header values
+    header_vals, IWA, OWA = [None] * 3
+
+    # Sky (Occulter) Transmission Map: tau_occ (T_sky):
+    if data["sky_trans"] is not None:
+        occ_trans_vals, _, bc = radial_average(
+            data["sky_trans"],
+            center=[headers["sky_trans"]["XCENTER"], headers["sky_trans"]["YCENTER"]],
+        )
+        occ_trans_wa = bc * headers["sky_trans"]["pixscale"]
+
+        # write to disk
+        hdul = fits.HDUList(
+            [
+                fits.PrimaryHDU(
+                    np.vstack((occ_trans_wa, occ_trans_vals)).transpose(),
+                    header=headers["sky_trans"],
+                )
+            ]
+        )
+        occ_trans_fname = os.path.join(outpath, f"{outname}occ_trans.fits")
+        hdul.writeto(occ_trans_fname, overwrite=overwrite)
+        header_vals = check_header_vals(header_vals, headers["sky_trans"])
+        IWA, OWA = update_WA_vals(IWA, OWA, occ_trans_wa)
+    else:
+        occ_trans_fname = None
+
+    # Off-Axis PSF maps: core_thruput, tau_core (Upsilon)
+    if (data["offax_psf_offset"] is not None) and (data["offax_psf"] is not None):
+        # pixel scale must be the same in both files
+        assert (
+            headers["offax_psf_offset"]["PIXSCALE"] == headers["offax_psf"]["PIXSCALE"]
+        ), "PIXSCALE in offax_psf and offax_psf_offset files is different."
+
+        # data must be two-dimensional:
+        assert (
+            2 in data["offax_psf_offset"].shape
+        ), "offax psf offset data must be 2-dimensional"
+        if data["offax_psf_offset"].shape[0] != 2:
+            data["offax_psf_offset"] = data["offax_psf_offset"].transpose()
+
+        # grab astrophysical source centers in pixel units
+        acents = data["offax_psf_offset"] / headers["offax_psf_offset"]["PIXSCALE"]
+
+        # grab image dimensions and generate resampled window
+        dims = data["offax_psf"][0].shape
+        window = genwindow([(d - 1) * resamp + 1 for d in dims])
+        # get photometric aperture radius in pixel units
+        rho = phot_aperture_radius / headers["offax_psf_offset"]["PIXSCALE"]
+        rho_resampled = rho * resamp
+
+        # allocate storage arrays
+        core_thruput_vals = np.zeros(len(data["offax_psf"]))
+        cents = np.zeros((len(data["offax_psf"]), 2))
+        if fit_gaussian:
+            core_areas = np.zeros(core_thruput_vals.shape)
+
+        for j in range(len(data["offax_psf"])):
+            # resample image and apply window around astrophysical offset
+            im = resample_image(data["offax_psf"][j], resamp=resamp)
+            windim = im * shift((window), acents[::-1, j] * resamp)
+            center = com(windim)
+            cents[j] = center
+
+            if fit_gaussian:
+                g = fitgaussian(windim)
+                # compute half-width at half max (NB: this is in rescaled pixel units)
+                hwhm = np.mean(np.abs(g[-2:] * np.sqrt(2 * np.log(2))))
+                if use_phot_aperture_as_min and (hwhm < rho_resampled):
+                    hwhm = rho_resampled
+                core_thruput_vals[j] = circ_aperture(
+                    im, hwhm, center, return_sum=True
+                ) / (resamp**2)
+                core_areas[j] = (
+                    np.pi
+                    * (hwhm / resamp * headers["offax_psf_offset"]["PIXSCALE"]) ** 2
+                )
+            else:
+                core_thruput_vals[j] = circ_aperture(
+                    im, rho_resampled, center, return_sum=True
+                ) / (resamp**2)
+
+        # now figure out the angles
+        # check if either dimension contains just one value
+        if np.all(data["offax_psf_offset"][0] == data["offax_psf_offset"][0][0]):
+            core_thruput_wa = data["offax_psf_offset"][1]
+        elif np.all(data["offax_psf_offset"][1] == data["offax_psf_offset"][1][0]):
+            core_thruput_wa = data["offax_psf_offset"][0]
+        else:
+            core_thruput_wa = np.sqrt(
+                data["offax_psf_offset"][0] ** 2 + data["offax_psf_offset"][1] ** 2
+            )
+        assert not (
+            np.all(core_thruput_wa == core_thruput_wa[0])
+        ), "Could not determine offax psf offsets"
+
+        # write to disk
+        hdr = headers["offax_psf_offset"].copy()
+        if fit_gaussian:
+            hdr["PHOTAPER"] = "Gaussian"
+            if use_phot_aperture_as_min:
+                hdr["MINAPER"] = phot_aperture_radius
+            else:
+                hdr["MINAPER"] = 0
+        else:
+            hdr["PHOTAPER"] = phot_aperture_radius
+
+        # core throughput:
+        hdul = fits.HDUList(
+            [
+                fits.PrimaryHDU(
+                    np.vstack((core_thruput_wa, core_thruput_vals)).transpose(),
+                    header=hdr,
+                )
+            ]
+        )
+        core_thruput_fname = os.path.join(outpath, f"{outname}core_thruput.fits")
+        hdul.writeto(core_thruput_fname, overwrite=overwrite)
+
+        # core area:
+        if fit_gaussian:
+            hdul = fits.HDUList(
+                [
+                    fits.PrimaryHDU(
+                        np.vstack((core_thruput_wa, core_areas)).transpose(),
+                        header=hdr,
+                    )
+                ]
+            )
+            core_area_fname = os.path.join(outpath, f"{outname}core_area.fits")
+            hdul.writeto(core_area_fname, overwrite=overwrite)
+        else:
+            # if a fixed aperture was used, just write the scalar value
+            core_area_fname = phot_aperture_radius**2 * np.pi
+
+        header_vals = check_header_vals(header_vals, headers["offax_psf"])
+        header_vals = check_header_vals(header_vals, headers["offax_psf_offset"])
+        IWA, OWA = update_WA_vals(IWA, OWA, core_thruput_wa)
+    else:
+        core_thruput_fname = None
+        core_area_fname = None
+
+    # Stellar intensity maps: core_mean_intensity (I_xy)
+    if (data["intens"] is not None) and (data["intens_diam"] is not None):
+        # pixel scale must be the same in both files
+        assert (
+            headers["intens"]["PIXSCALE"] == headers["intens_diam"]["PIXSCALE"]
+        ), "PIXSCALE in intens and intens_diam files is different."
+
+        # stellar diameter list must be the same length as data
+        assert len(data["intens_diam"]) == len(
+            data["intens"]
+        ), "intens and intens_diam must have the same length"
+
+        # get first radial profile
+        radintens, _, bc = radial_average(
+            data["intens"][0],
+            center=[headers["intens"]["XCENTER"], headers["intens"]["YCENTER"]],
+        )
+
+        # allocate output
+        mean_intens = np.zeros((len(data["intens"]), len(radintens)))
+        mean_intens[0] = radintens
+
+        for j in range(1, len(data["intens"])):
+            mean_intens[j], _, _ = radial_average(
+                data["intens"][j],
+                center=[headers["intens"]["XCENTER"], headers["intens"]["YCENTER"]],
+            )
+
+        mean_intens_wa = bc * headers["intens"]["pixscale"]
+        out = np.vstack((mean_intens_wa, mean_intens))
+        hdr = headers["intens_diam"]
+        for j, val in enumerate(data["intens_diam"]):
+            hdr[f"DIAM{j :03d}"] = val
+
+        # write out:
+        hdul = fits.HDUList(
+            [
+                fits.PrimaryHDU(
+                    out.transpose(),
+                    header=hdr,
+                )
+            ]
+        )
+
+        core_mean_intensity_fname = os.path.join(
+            outpath, f"{outname}core_mean_intensity.fits"
+        )
+        hdul.writeto(core_mean_intensity_fname, overwrite=overwrite)
+
+        header_vals = check_header_vals(header_vals, headers["intens"])
+        header_vals = check_header_vals(header_vals, headers["intens_diam"])
+        IWA, OWA = update_WA_vals(IWA, OWA, mean_intens_wa)
+    else:
+        core_mean_intensity_fname = None
+
+    angunit = ((header_vals["lam"] * u.nm) / (header_vals["pupilDiam"] * u.m)).to(
+        u.arcsec, equivalencies=u.dimensionless_angles()
+    )
+    outdict = {
+        "pupilDiam": header_vals["pupilDiam"],
+        "obscurFac": header_vals["obscurFac"],
+        "shapeFac": np.pi / 4,
+        "starlightSuppressionSystems": [
+            {
+                "name": name,
+                "lam": header_vals["lam"],
+                "deltaLam": header_vals["deltaLam"],
+                "occ_trans": occ_trans_fname,
+                "core_thruput": core_thruput_fname,
+                "core_mean_intensity": core_mean_intensity_fname,
+                "core_area": core_area_fname,
+                "IWA": (IWA * angunit).to(u.arcsec).value,
+                "OWA": (OWA * angunit).to(u.arcsec).value,
+            }
+        ],
+    }
+
+    script_fname = os.path.join(outpath, f"{outname}_specs.json")
+    with open(script_fname, "w") as f:
+        json.dump(outdict, f)
+
+    return outdict
+
+
+def check_header_vals(header_vals, hdr):
+    """Utility method for checking values in headers
+
+    Args:
+        header_vals (dict, optional):
+            Current value set
+        hdr (numpy.ndarray):
+            Header
+
+    Returns:
+        dict:
+            Updated header values
+
+    """
+    lam = hdr["LAMBDA"] * 1000
+    deltaLam = (hdr["MAXLAM"] - hdr["MINLAM"]) * 1000
+    pupilDiam = hdr["D"]
+    obscurFac = hdr["OBSCURED"]
+
+    if header_vals is None:
+        header_vals = {}
+
+    inconsistent = False
+    names = ["lam", "deltaLam", "pupilDiam", "obscurFac"]
+    vals = [lam, deltaLam, pupilDiam, obscurFac]
+
+    for n, v in zip(names, vals):
+        if n not in header_vals:
+            header_vals[n] = v
+        else:
+            if header_vals[n] != v:
+                inconsistent = True
+
+    if inconsistent:
+        warnings.warn("Inconsistent header values in inputs.")
+
+    return header_vals
+
+
+def update_WA_vals(IWA, OWA, WAs):
+    """Utility method for updating global IWA/OWA
+
+    Args:
+        IWA (float, optional):
+            Current IWA value or None
+        OWA (float, optional):
+            Current OWA value or None
+        WAs (numpy.ndarray):
+            Angular separations of current system
+
+    Returns:
+        tuple:
+            IWA (float or None)
+            OWA (float or None)
+    """
+
+    if IWA is not None:
+        if IWA < np.min(WAs):
+            IWA = np.min(WAs)
+    else:
+        IWA = np.min(WAs)
+
+    if OWA is not None:
+        if OWA > np.max(WAs):
+            OWA = np.max(WAs)
+    else:
+        OWA = np.max(WAs)
+
+    return IWA, OWA
diff --git a/EXOSIMS/util/radialfun.py b/EXOSIMS/util/radialfun.py
new file mode 100644
index 000000000..e41d2858b
--- /dev/null
+++ b/EXOSIMS/util/radialfun.py
@@ -0,0 +1,258 @@
+"""
+Utilities for radial computations on rectangular data arrays
+"""
+
+import numpy as np
+from scipy.interpolate import RectBivariateSpline
+from scipy import optimize
+
+
+def pixel_dists(dims, center):
+    """
+    Compute pixel distances from center of an image
+
+    Args:
+        dims (tuple(float,float)):
+            Image dimensions
+        center (list(float,float)):
+            [x,y] pixel coordinates of center
+
+    Returns:
+        numpy.ndarray:
+            Array of dimension dims with distance from center of each pixel
+    """
+
+    y, x = np.indices(dims)
+    r = np.sqrt((x - center[0]) ** 2 + (y - center[1]) ** 2)
+
+    return r
+
+
+def radial_average(im, center=None, nbins=None):
+    """
+    Compute radial average on an image
+
+    Args:
+        im (numpy.ndarray):
+            The input image. Must be 2-dimensional
+        center (list(float,float), optional):
+            [x,y] pixel coordinates of center to compute average about.
+            If None (default) use geometric center of input.
+        nbins (int, optional):
+            Number of bins to compute average in. If None (default) then set to
+            floor(N/2) where N is the maximum dimension of the input image.
+
+    Returns:
+        tuple:
+            means (numpy.ndarray):
+                ``nbins`` element array with radial average values
+            bins (numpy.nadarray):
+                ``nbins+1`` element array with bin boundaries.
+            bincents (numpy.ndarray):
+                ``nbins`` elements array with bin midpoints. Equivalent to
+                ``(bins[1:] + bins[:-1]) / 2``
+    """
+
+    # gather info
+    dims = im.shape
+    assert len(dims) == 2, "Only 2D images are supported."
+
+    if center is None:
+        center = [dims[0] / 2.0, dims[1] / 2.0]
+
+    if nbins is None:
+        nbins = int(np.floor(np.max(dims) / 2))
+
+    # compute pixel distances from center
+    r = pixel_dists(dims, center)
+
+    # define bins and digitize distanes
+    bins = np.linspace(0, np.max(r), nbins + 1)
+    bincents = (bins[1:] + bins[:-1]) / 2
+    inds = np.digitize(r.ravel(), bins)
+    # max value will be in its own bin, so put all matching pixels in the last valid bin
+    inds[inds == nbins + 1] = nbins
+
+    # compute means in each bin
+    means = np.zeros(nbins)
+    imflat = im.ravel()
+    for j in range(1, nbins + 1):
+        means[j - 1] = np.nanmean(imflat[inds == j])
+
+    return means, bins, bincents
+
+
+def circ_aperture(im, rho, center, return_sum=False):
+    """
+    Extract pixels in circular aperture
+
+    Args:
+        im (numpy.ndarray):
+            The input image. Must be 2-dimensional
+        rho (float):
+            Radius of aperture (in pixels)
+        center (list(float,float):
+            [x,y] pixel coordinates of center of aperture
+        return_sum (bool):
+            Return sum. Defaults False: returns all pixels in aperture.
+
+    Returns:
+        numpy.ndarray:
+            1-dimensional array of pixel values inside aperture
+    """
+
+    # compute pixel distances from center
+    r = pixel_dists(im.shape, center)
+
+    pix = im[r <= rho]
+
+    if return_sum:
+        out = np.sum(pix[np.isfinite(pix)])
+    else:
+        out = pix
+
+    return out
+
+
+def com(im0, fill_val=0):
+    """
+    Find the center-of-mass centroid of an image
+
+    Args:
+        im (numpy.ndarray):
+            The input image. Must be 2-dimensional
+        fill_val (float):
+            Replace any non finite values in the image with this value before
+            resampling. Defaults to zero.
+
+    Returns:
+        list:
+            [x,y] pixel coordinates of COM
+    """
+
+    # operate on input copy and zero out bad values
+    im = im0.copy()
+    im[~np.isfinite(im)] = fill_val
+
+    y, x = np.indices(im.shape)
+
+    return [np.sum(im * inds) / np.sum(im) for inds in [x, y]]
+
+
+def genwindow(dims):
+    """
+    Create a 2D Hann window of the given dimensions
+
+    Args:
+        dims (tuple or list):
+            2-element dimensions of window (can be the .shape output of an ndarray)
+
+    Returns:
+        numpy.ndarray:
+            Window of dimensions ``dims``.
+
+    """
+
+    window = np.ones(dims)
+    y, x = np.indices(dims)
+    for dim, inds in zip(dims, [x, y]):
+        window *= 0.5 - 0.5 * np.cos(2 * np.pi * inds / dim)
+    window = np.sqrt(window)
+
+    return window
+
+
+def resample_image(im, resamp=2, fill_val=0):
+    """
+    Create a resampled image
+
+    Args:
+        im (numpy.ndarray):
+            The input image. Must be 2-dimensional
+        resamp (float):
+            Resampling factor. Must be >=1. Defaults to 2
+        fill_val (float):
+            Replace any non finite values in the image with this value before
+            resampling. Defaults to zero.
+
+    Returns:
+        numpy.ndarray:
+            Resampled image.
+
+    """
+
+    assert resamp >= 1, "resamp must be >= 1"
+    if resamp == 1:
+        return im
+
+    dims = im.shape
+    newdims = [(d - 1) * resamp + 1 for d in dims]
+
+    imc = im.copy()
+    imc[~np.isfinite(imc)] = fill_val
+    sp = RectBivariateSpline(np.arange(dims[0]), np.arange(dims[0]), imc)
+    imresamp = sp(np.arange(newdims[0]) / resamp, np.arange(newdims[1]) / resamp)
+
+    return imresamp
+
+
+def gaussian(a, x0, y0, sx, sy):
+    """Gaussian function
+
+    Args:
+        a (float):
+            Amplitude
+        x0 (float):
+            Center (mean) x position
+        y0 (float):
+            Center (mean) y position
+        sx (float):
+            Standard deviation in x
+        xy (float):
+            Standard deviation in y
+
+    Returns:
+        lambda:
+            Callable lambda function with input x,y returning value of Gaussian at those
+            coordinates
+    """
+
+    return lambda x, y: a * np.exp(
+        -((x - x0) ** 2) / 2 / sx**2 - (y - y0) ** 2 / 2 / sy**2
+    )
+
+
+def fitgaussian(im):
+    """Fit a 2D Gaussian to data
+
+    Args:
+        im (numpy.ndarray):
+            2D data array
+
+    Returns:
+        tuple:
+            a (float):
+                Amplitude
+            x0 (float):
+                Center (mean) x position
+            y0 (float):
+                Center (mean) y position
+            sx (float):
+                Standard deviation in x
+            xy (float):
+                Standard deviation in y
+    """
+
+    Y, X = np.indices(im.shape)
+    total = im.sum()
+    x0 = (X * im).sum() / total
+    y0 = (Y * im).sum() / total
+    col = im[:, int(x0)]
+    sx0 = np.sqrt(np.abs((np.arange(col.size) - x0) ** 2 * col).sum() / col.sum() / 2)
+    row = im[int(y0), :]
+    sy0 = np.sqrt(np.abs((np.arange(row.size) - y0) ** 2 * row).sum() / row.sum() / 2)
+    a0 = im.max()
+
+    errorfunction = lambda p: np.ravel(gaussian(*p)(X, Y) - im)
+    p, success = optimize.leastsq(errorfunction, (a0, x0, y0, sx0, sy0))
+    return p
diff --git a/documentation/EXOSIMS.util.rst b/documentation/EXOSIMS.util.rst
index bcbeec8f3..d8a2fd634 100644
--- a/documentation/EXOSIMS.util.rst
+++ b/documentation/EXOSIMS.util.rst
@@ -169,6 +169,14 @@ EXOSIMS.util.phaseFunctions module
    :undoc-members:
    :show-inheritance:
 
+EXOSIMS.util.photometricModels module
+-------------------------------------
+
+.. automodule:: EXOSIMS.util.photometricModels
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
 EXOSIMS.util.planet\_star\_separation module
 --------------------------------------------
 
@@ -177,6 +185,22 @@ EXOSIMS.util.planet\_star\_separation module
    :undoc-members:
    :show-inheritance:
 
+EXOSIMS.util.process\_opticalsys\_package module
+------------------------------------------------
+
+.. automodule:: EXOSIMS.util.process_opticalsys_package
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+EXOSIMS.util.radialfun module
+-----------------------------
+
+.. automodule:: EXOSIMS.util.radialfun
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
 EXOSIMS.util.read\_ipcluster\_ensemble module
 ---------------------------------------------
 
diff --git a/documentation/OS_init.png b/documentation/OS_init.png
new file mode 100644
index 000000000..622f651ea
Binary files /dev/null and b/documentation/OS_init.png differ
diff --git a/documentation/Traub_v_Vega_F0.png b/documentation/Traub_v_Vega_F0.png
new file mode 100644
index 000000000..6bb27da96
Binary files /dev/null and b/documentation/Traub_v_Vega_F0.png differ
diff --git a/documentation/arglist.rst b/documentation/arglist.rst
index e6e873526..842b84724 100644
--- a/documentation/arglist.rst
+++ b/documentation/arglist.rst
@@ -24,6 +24,10 @@ The table below includes a list of all Prototype module inputs.
       - :py:class:`~EXOSIMS.Prototypes.Observatory.Observatory`
     * - ``arange``
       - :py:class:`~EXOSIMS.Prototypes.PlanetPopulation.PlanetPopulation`
+    * - ``bandpass_model``
+      - :py:class:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem`
+    * - ``bandpass_step``
+      - :py:class:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem`
     * - ``binaryleakfilepath``
       - :py:class:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem`
     * - ``BW``
@@ -62,10 +66,6 @@ The table below includes a list of all Prototype module inputs.
       - :py:class:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem`
     * - ``core_thruput``
       - :py:class:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem`
-    * - ``CTE_derate``
-      - :py:class:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem`
-    * - ``dark_derate``
-      - :py:class:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem`
     * - ``defaultAddExoplanetObsTime``
       - :py:class:`~EXOSIMS.Prototypes.SurveySimulation.SurveySimulation`
     * - ``defburnPortion``
@@ -142,8 +142,6 @@ The table below includes a list of all Prototype module inputs.
       - :py:class:`~EXOSIMS.Prototypes.Observatory.Observatory`
     * - ``koAngles_Sun``
       - :py:class:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem`
-    * - ``kRN``
-      - :py:class:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem`
     * - ``lam``
       - :py:class:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem`
     * - ``lam_c``
@@ -252,8 +250,6 @@ The table below includes a list of all Prototype module inputs.
       - :py:class:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem`
     * - ``ref_Time``
       - :py:class:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem`
-    * - ``refl_derate``
-      - :py:class:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem`
     * - ``Rprange``
       - :py:class:`~EXOSIMS.Prototypes.PlanetPopulation.PlanetPopulation`
     * - ``Rs``
@@ -312,12 +308,12 @@ The table below includes a list of all Prototype module inputs.
       - :py:class:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem`
     * - ``twotanks``
       - :py:class:`~EXOSIMS.Prototypes.Observatory.Observatory`
-    * - ``use_char_minintTime``
-      - :py:class:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem`
     * - ``varEZ``
       - :py:class:`~EXOSIMS.Prototypes.ZodiacalLight.ZodiacalLight`
     * - ``verbose``
       - :py:class:`~EXOSIMS.MissionSim.MissionSim`
+    * - ``VmagFill``
+      - :py:class:`~EXOSIMS.Prototypes.StarCatalog.StarCatalog`
     * - ``whichPlanetPhaseFunction``
       - :py:class:`~EXOSIMS.Prototypes.PlanetPhysicalModel.PlanetPhysicalModel`
     * - ``wrange``
diff --git a/documentation/boyajian_model_v_meas.png b/documentation/boyajian_model_v_meas.png
new file mode 100644
index 000000000..30e0b0a5b
Binary files /dev/null and b/documentation/boyajian_model_v_meas.png differ
diff --git a/documentation/boyajian_model_v_stefan-boltzmann.png b/documentation/boyajian_model_v_stefan-boltzmann.png
new file mode 100644
index 000000000..938af957d
Binary files /dev/null and b/documentation/boyajian_model_v_stefan-boltzmann.png differ
diff --git a/documentation/concepts.rst b/documentation/concepts.rst
index 6e6e4a520..c3733deb2 100644
--- a/documentation/concepts.rst
+++ b/documentation/concepts.rst
@@ -1,13 +1,16 @@
+.. _concepts:
+
 Fundamental Concepts
-========================
+##########################
 
 This is a brief summary of fundamental physical concepts underlying the code, and how they are treated in the code.  Many more details are available in the :ref:`refs`.
 
-
+.. _orbgeom:
+   
 Orbit Geometry
-------------------------
+========================
 
-An exoplanet in ``EXOSIMS`` is defined via a set of scalar orbital and physical parameters. For each target star :math:`S`, we define a reference frame  :math:`\mathcal{S} = (\mathbf{\hat s}_1, \mathbf{\hat s}_2, \mathbf{\hat s}_3)`, with :math:`\mathbf{\hat s}_3` pointing along the vector from the observer to the star (:math:`\mathbf{\hat{r}}_{S/\textrm{observer}} \equiv -\mathbf{\hat{r}}_{\textrm{observer}/S}`) such that the plane of the sky (the plane orthogonal to the this vector lies in the :math:`\mathbf{\hat s}_1-\mathbf{\hat s}_2` plane, as in :numref:`fig:orbit_diagram`.  The :math:`\mathcal{S}` is fixed at the time of mission start, and does not evolve throughout the mission simulation, making :math:`\mathcal{S}` a true inertial frame. While the orientation of :math:`\mathbf{\hat s}_3)` is arbitrary, we take it to be the same inertially fixed direction for all targets (by default equivalent to celestial north). 
+An exoplanet in ``EXOSIMS`` is defined via a set of scalar orbital and physical parameters. For each target star :math:`S`, we define a reference frame  :math:`\mathcal{S} = (\mathbf{\hat s}_1, \mathbf{\hat s}_2, \mathbf{\hat s}_3)`, with :math:`\mathbf{\hat s}_3` pointing along the vector from the observer to the star (:math:`\mathbf{\hat{r}}_{S/\textrm{observer}} \equiv -\mathbf{\hat{r}}_{\textrm{observer}/S}`) such that the plane of the sky (the plane orthogonal to the this vector lies in the :math:`\mathbf{\hat s}_1-\mathbf{\hat s}_2` plane, as in :numref:`fig:orbit_diagram`.  The :math:`\mathcal{S}` frame is fixed at the time of mission start, and does not evolve throughout the mission simulation, making :math:`\mathcal{S}` a true inertial frame. While the orientation of :math:`\mathbf{\hat s}_3)` is arbitrary, we take it to be the same inertially fixed direction for all targets (by default equivalent to celestial north). 
 
 .. _fig:orbit_diagram:
 .. figure:: orbit_diagram.png
@@ -42,15 +45,188 @@ An imaging detection measures the projection of the orbital radius onto the plan
 
     .. math::
         
-        s \triangleq \Vert\mathbf{s}\Vert = \frac{r}{4} \sqrt{4 \cos{\left (2 I \right )} + 4 \cos{\left (2 \theta \right )} - 2 \cos{\left (2 I - 2 \theta \right )} - 2 \cos{\left (2 I + 2 \theta \right )} + 12}
+        s \triangleq \Vert\mathbf{s}\Vert = r \sqrt{1 - \sin^{2}{\left(I \right)} \sin^{2}{\left(\theta \right)}}
 
-The angular separation can be calculated as 
+The calculation of this value from Keplerian orbital elements is provided in EXOSIMS by method :py:meth:`~EXOSIMS.util.planet_star_separation.planet_star_separation`. The angular separation associated with the projected separation can be calculated as: 
      
    .. math::
 
       \alpha = \tan^{-1}\left( \frac{s}{d} \right)
 
-where :math:`d` is the distance between the observer and the target star.  In the small angle approximation (which applies in all cases) this can be simplified to :math:`s/d`.
+where :math:`d` is the distance between the observer and the target star.  In the small angle approximation (which applies in essentially all cases) this can be simplified to :math:`s/d`. EXOSIMS typically does not make such small angle approximations (other than when explicitly noted, as in the case of the phase angle - see :ref:`below<betacalcref>`), just in case you're trying to do something weird. And because we can.
+
+.. _photometry:
+   
+Photometry
+========================
+
+In general, spectral flux density in a given observing band can be approximated as:
+
+.. _fluxdenscalcref:
+   
+   .. math::
+      
+      f = \mc{F_0} 10^{-0.4 m}
+
+where :math:`\mc{F_0}` is the band-specific zero-magnitude spectral flux density (vegamag, by convention), and :math:`m` is the band-specific apparent magnitude of the observed object. Multiplying :math:`f` by the bandwidth (:math:`\Delta\lambda`) of the observing band (see: :ref:`observing_bands`) gives the approximate flux for the observation:
+
+   .. math::
+      
+      F = f\Delta\lambda
+
+Further scaling by the effective collecting area (:math:`A`), all other system throughput losses (:math:`\tau`), and detector quantum efficiency (QE) gives the count rate for the observation in counts/second (or electrons per second):
+
+   .. math::
+      
+      C = F A \tau \textrm{QE}
+
+``EXOSIMS`` utilizes photon-wavelength units for spectral flux densities by default (akin to IRAF/synphot ``photlam``; see: https://synphot.readthedocs.io/en/latest/synphot/units.html) but with arbitrary area and wavelength units.  Spectral flux densities are thus typically encoded with default units of either :math:`\textrm{ photons m}^{-2}\textrm{ s}^{-1}\, \textrm{nm}^{-1}` or ``photlam``, which is :math:`\textrm{ photons cm}^{-2}\textrm{ s}^{-1}\, \mathring{A}^{-1}`. As all quantities have associated units, unit conversion is automatic and occurs as needed in all calculations, and there is never an ambiguity in the units of a particular quantity. See :ref:`OpticalSystem` for further details.
+
+
+Stellar Photometry
+---------------------
+
+Following the :ref:`equation<fluxdenscalcref>` above, a star's spectral flux density in a given observing band can be approximated as:
+
+   .. math::
+      
+      f = \mc{F_0} 10^{-0.4 m_S}
+
+where :math:`m_S` is the star's apparent magnitude in the observing band. If the observing band happens to match (or nearly match) a band where the apparent magnitude of a target star is already known, then both :math:`\mc{F_0}` and :math:`m_S` can simply be looked up from cataloged values, which was the approach in ``EXOSIMS`` pre-2016.  However, one of the major use cases of ``EXOSIMS`` is the analysis of observations in a variety of narrow (and possibly non-standard) bands, which requires better modeling to achieve sufficient fidelity of results.  
+
+Between software versions 1.0 and 2.0, ``EXOSIMS`` solely utilized the empirical relationships from [Traub2016]_ to evaluate stellar fluxes in arbitrary bands. The equations in that work (Sec. 2.2) are equivalent to:
+
+   .. math::
+   
+      \begin{split}  \mc{F_0} &= 10^{4.01 - \left(\frac{\lambda_0}{1\mu\mathrm{m}} - 0.55\right)/0.77} \, \textrm{ photons cm}^{-2}\textrm{ s}^{-1}\, \mathrm{nm}^{-1} \\ 
+      m_S &= V + b(B-V)\left(\frac{1 \mu\mathrm{m}}{\lambda_0} - 1.818\right)
+      \end{split}
+
+where :math:`\lambda_0` is the center of the observing bandpass (or average wavelength, or effective wavelength), :math:`V` is the target's apparent Johnson-V band magnitude, :math:`B-V` is the target's B-V color, and scaling factor :math:`b` is given by:
+
+   .. math::
+      
+      b = \begin{cases} 2.20 & \lambda < 0.55\,\mu\textrm{m}\\ 1.54 & \textrm{else} \end{cases}
+
+[Traub2016]_ states that this parametrization is limited to the range :math:`0.4\,\mu\mathrm{m} < \lambda < 1.0\,\mu\mathrm{m}` and that fluxes calculated in this way are accurate to within approximately 7% in this range. The equations are implemented in EXOSIMS in :py:meth:`~EXOSIMS.util.photometricModels.TraubStellarFluxDensity`.
+
+.. _fig:Traub_v_Vega_F0:
+.. figure:: Traub_v_Vega_F0.png
+   :width: 100.0%
+   :alt: Zero magnitude flux comparison 
+    
+   :math:`\mc F_0` computed using the [Traub2016]_ equation compared with Vega's spectral flux density in a 10% band for  :math:`0.4\,\mu\mathrm{m} < \lambda < 1.0\,\mu\mathrm{m}`.
+
+:numref:`fig:Traub_v_Vega_F0` shows a comparison of the zero-magnitude spectral flux density computed from the [Traub2016]_ equation, compared to a calculation of Vega's spectral density in a 10% band for the full valid wavelength range of the [Traub2016]_ equations, using the spectrum of Vega shown in :numref:`fig:pickles_bpgs_G0V` (``synphot``'s default). The values agree to better than 7%, on average, confirming the statements made in the original paper. 
+
+
+Template Spectra
+""""""""""""""""""""
+To move beyond the wavelength restrictions of the [Traub2016]_ equations, starting circa version 2.0, ``EXOSIMS`` began augmenting these with flux calculations based on template spectra.
+
+Starting with version 3.1, ``EXOSIMS`` now uses the ``synphot`` package (https://synphot.readthedocs.io/) for handling photometric calculations based on template spectra. This is a highly mature piece of software, with heritage tracing back to STSDAS SYNPHOT in IRAF and PYSYNPHOT in ASTROLIB.  In order to accurately model the stellar flux in any arbitrary observing band for any spectral type, ``EXOSIMS`` makes use of two spectral catalogs:
+
+#. The `Pickles Atlas <https://www.stsci.edu/hst/instrumentation/reference-data-for-calibration-and-tools/astronomical-catalogs/pickles-atlas>`_ (specifically the UVKLIB spectra) - 131 flux calibrated stellar spectra covering all normal spectral types and luminosity classes at solar abundance.
+#. The `Bruzual-Persson-Gunn-Stryker Atlas <https://www.stsci.edu/hst/instrumentation/reference-data-for-calibration-and-tools/astronomical-catalogs/bruzual-persson-gunn-stryker-atlas-list>`_ (BPGS).
+
+All Pickles spectra are normalized to 0 magnitude in vegamag in V band, while all BPGS spectra are normalized to a zero visual magnitude. ``EXOSIMS`` preferentially uses the Pickles spectra and only uses BPGS when the spectral type is stated.
+
+.. _fig:pickles_bpgs_G0V:
+.. figure:: pickles_bpgs_G0V.png
+   :width: 100.0%
+   :alt: G0V spectra from Pickles and BPGS. 
+    
+   G0V spectra from BPGS, also normalized to zero vegamag using synphot, along with synphot's vega spectrum and Johnson-V bandpass.
+
+:numref:`fig:pickles_bpgs_G0V` shows two G0V spectra pulled from each of the two atlases, along with ``synphot``'s default Vega spectrum and Johnson-V filter profile. The values in the legend represent the total integrated flux of each spectrum in the V-band filter. Re-normalizing to zero vegamag has minimal effect on both spectra, but does highlight the differences between their normalizations and the Vega spectrum used preferentially by ``synphot``. :numref:`fig:pickles_bpgs_G0V_diffs` shows the differences between the original spectra and their normalizations, as well as the difference between the two normalized spectra, which typically agree to within :math:`\sim 100 \textrm{ photons cm}^{-2}\textrm{ s}^{-1}\, \mathring{A}^{-1}`.
+
+.. _fig:pickles_bpgs_G0V_diffs:
+.. figure:: pickles_bpgs_G0V_diffs.png
+   :width: 100.0%
+   :alt: Difference between original and re-normalized G0V spectra from Pickles and BPGS. 
+    
+   Difference between original and re-normalized G0V spectra from Pickles and BPGS.
+
+The basic procedure for evaluating the stellar flux based on template spectra for a given observing band is:
+
+#. Match the closest available catalog spectrum to the target's spectral type. (At this point we can also optionally apply interstellar reddening, but do not, by default.) 
+#. Identify the closest (in wavelength) band (see :numref:`fig:synphot_standard_bands`) to the desired observing band, for which the original star catalog provided an apparent magnitude value.
+#. Re-normalize the catalog spectrum to the target star's magnitude in the identified band.
+#. Integrate the spectrum over the observing band to find the stellar flux for the observation.
+
+.. important::
+
+    Computing stellar fluxes directly from template spectra bypasses the need to evaluate (or look up) the zero-magnitude flux in the obserivng band.  However, if the zero-magnitude flux is needed for other calculations, it cannot be replaced with the stellar flux, and must be computed separately.
+
+:numref:`fig:traub_v_synphot_Vband` shows a comparison of these two calculations (``synphot`` vs. the [Traub2016]_ equations) for the subset of stars from the EXOCAT star catalog that have spectral types exactly matching entries in the Pickles Atlas.  The fluxes are evaluated for a V-band-like observing band with a central wavelength of 549 nm and a Gaussian-equivalent FWHM of 81 nm.  This equates to a bandwidth of 85.73 nm, which is the value used for scaling the Traub et al. spectral fluxes.  Unsurprisingly (as the original [Traub2016]_ fits were geared towards V band observations) the two calculations have excellent agreement, differing by only about 1%, on average.
+
+.. _fig:traub_v_synphot_Vband:
+.. figure:: traub_v_synphot_Vband.png
+   :width: 100.0%
+   :alt: Stellar flux calculation for V band comparison  
+    
+   ``synphot`` Stellar flux calculations using Pickles Atlas templates vs. the [Traub2016]_ parametric calculation. The points represent 1327 individual target stars and the reference line has slope 1.
+
+We can repeat this experiment again, this time looking at B-band-like observations (439 nm filter with FWHM of 80 nm and equivalent bandwidth of 85 nm), with results shown in :numref:`fig:traub_v_synphot_Bband`.  IN this case, we perform the ``synphot`` calculations twice: first re-normalizing each target's template spectrum by its cataloged V-band magnitude (in the Johnson-V band) and next re-normalizing the template spectrum by the cataloged B-band magnitude (in the Johnson B-band).  Once again, if we normalize in the appropriate band, the agreement between the template spectrum calculations and the [Traub2016]_ fits agree very well, with average deviations of only a few percent.  Normalizing to V band magnitudes, however, produces averages of 10% error, indicating that use of the empirical relationship may be better in cases where cataloged band magnitudes (or colors) do not exist for a target star.
+
+.. _fig:traub_v_synphot_Bband:
+.. figure:: traub_v_synphot_Bband.png
+   :width: 100.0%
+   :alt: Stellar flux calculation for B band comparison  
+    
+   ``synphot`` Stellar flux calculations using Pickles Atlas templates vs. the [Traub2016]_ parametric calculation. The points represent 1327 individual target stars and the reference line has slope 1. One set of points represent ``synphot`` calculations were template spectra were re-normalized by the cataloged target V-band magnitudes, while the other set represents re-normalization by the cataloged B-band magnitudes.
+
+Finally, we can consider the case of an observing band strictly outside of the stated valid range of the [Traub2016]_ equations. We repeat the same calculations as in :numref:`fig:traub_v_synphot_Bband`, but now using K-band magnitudes and a very narrow observing band (2195 nm filter with FWHM of 19 nm and equivalent bandwidth of 20 nm), with results shown in :numref:`fig:traub_v_synphot_Kband`. In this case the ``synphot`` results diverge sharply from the [Traub2016]_ model, with average errors of hundreds of percent, depending on which normalization is used. 
+
+.. _fig:traub_v_synphot_Kband:
+.. figure:: traub_v_synphot_Kband.png
+   :width: 100.0%
+   :alt: Stellar flux calculation for K band comparison  
+    
+   ``synphot`` Stellar flux calculations using Pickles Atlas templates vs. the [Traub2016]_ parametric calculation. The points represent 1285 individual target stars and the reference line has slope 1. One set of points represent ``synphot`` calculations were template spectra were re-normalized by the cataloged target V-band magnitudes, while the other set represents re-normalization by the cataloged K-band magnitudes.
+
+We can also look at the effects of bandwidth on the [Traub2016]_ empirical relationship. :numref:`fig:traub_v_synphot_deltaLambda_errs` shows the percent differences between the stellar fluxes computed via the [Traub2016]_ equations and with ``synphot`` template spectra as a function of fractional bandwidths for different spectral and luminosity classes. The central wavelength in all cases is 600 nm (the center of the stated valid range for the [Traub2016]_ equations). The black dashed line represents the limit of the valid range - past this point, the errors are nearly linear in fractional bandwidth for all spectral types considered here (but especially for all of the main sequence spectra. As the data set used to generate the [Traub2016]_ equations contained primarily dwarf star spectra, it is unsurprising that the equations do a much better job of modeling these spectra than those from other luminosity classes.  However, even in the case of giant spectra, the differences between the two calculations (within the valid wavelength range of the equations) is typically well under 10%.
+
+.. _fig:traub_v_synphot_deltaLambda_errs:
+.. figure:: traub_v_synphot_deltaLambda_errs.png
+   :width: 100.0%
+   :alt: synphot vs Traub2016 stellar fluxes as function of fractional bandwidth
+    
+   Percent differences between ``synphot`` and [Traub2016]_ stellar fluxes as a function of fractional bandwidth with a central wavelength of 600 nm for three different spectral types. The dashed line represents the limit of the stated region of validity of the [Traub2016]_ equations.
+
+The one exception is the K5V spectrum, which has a qualitatively different pattern of differences from the others.  To check whether this is limited to this specific spectrum, we repeat the same calculations using template spectra spanning the whole main sequence. :numref:`fig:traub_v_synphot_deltaLambda_errs_mainseq` repeats the calculations from :numref:`fig:traub_v_synphot_deltaLambda_errs`, but only for main sequence spectral templates. 
+
+.. _fig:traub_v_synphot_deltaLambda_errs_mainseq:
+.. figure:: traub_v_synphot_deltaLambda_errs_mainseq.png
+   :width: 100.0%
+   :alt: synphot vs Traub2016 stellar fluxes as function of fractional bandwidth for main sequence spectra
+    
+   Same as :numref:`fig:traub_v_synphot_deltaLambda_errs`, except using only main sequence spectra.
+
+We can see that the [Traub2016]_ works best for F, G, and early K stars, holds reasonably well for most early-type stars, and starts to diverge significantly for late-type stars, and especially late K and M dwarfs. 
+
+.. _modelingir:
+
+Modeling Mid- to Far-IR Instruments
+"""""""""""""""""""""""""""""""""""""
+
+A fundamental limitation of the template spectra is that they extend only to approximately 2.5 :math:`\mu\mathrm{m}`.  If we wish to model instruments operating beyond this wavelength, then we need to either replace our template spectra with ones covering longer wavelengths, or to rely on idealized black-body curves, parameterized by the stellar effective temperature.  By default, ``EXOSIMS`` does the latter. 
+
+As black-body spectra are parametrized by stellar effective temperature, we need to either have these values tabulated in the original star catalog, or to compute them.  Where catalog values are unavailable, ``EXOSIMS`` utilizes the empirical fit from  [Ballesteros2012]_ (Eq. 14), which has the form:
+
+.. math::
+
+   T_\mathrm{eff} = 4600\left(\frac{1}{0.92(B-V) + 1.7} + \frac{1}{0.92(B-V)+0.6}\right) \, \mathrm{K}
+
+To validate this relationship, we use our template spectra, compute their B-V colors, compute the effective temperatures, and then compare the resulting black-body spectra (normalized to the same V magnitude) to the original ones.  In general, we find excellent agreement past 2 :math:`\mu\mathrm{m}`.  :numref:`fig:templates_w_blackbody` shows a sample of this comparison for various spectral and luminosity classes. 
+
+.. _fig:templates_w_blackbody:
+.. figure:: templates_w_blackbody.png
+   :width: 100.0%
+   :alt: Template spectra with corresponding black-body spectra
+    
+   Template spectra with black-body spectra (black dashed lines) for various spectral types, with stellar effective temperature computed using the [Ballesteros2012]_ fit.
+
 
 Planet Photometry
 ------------------------
@@ -61,7 +237,11 @@ The second quantity observed by direct imaging is the flux ratio between the pla
         
         \Delta{\textrm{mag}} \triangleq -2.5\log_{10}\left(\frac{F_P}{F_S}\right) =  -2.5\log_{10}\left(p\Phi(\beta) \left(\frac{R_P}{r}\right)^2 \right)
 
-where :math:`p` is the planet's geometric albedo, :math:`R_P` is the planet's (equatorial) radius, and :math:`\Phi` is the planet's phase function, which is parameterized by phase angle :math:`\beta`.
+where :math:`p` is the planet's geometric albedo, :math:`R_P` is the planet's (equatorial) radius, and :math:`\Phi` is the planet's phase function (see: :ref:`phasefun`), which is parameterized by phase angle :math:`\beta`.  A planet's flux can therefore be calculated from the star's flux and an assumed :math:`\Delta{\textrm{mag}}` as:
+
+    .. math::
+        
+        F_P = F_S 10^{-0.4 \Delta\textrm{mag}}
 
 The phase angle is the illuminant-object-observer angle, and therefore the angle between the planet-star vector ( :math:`\mathbf{r}_{S/P} \equiv -\mathbf{r}_{P/S}`) and the planet-observer vector :math:`\mathbf{r}_{\textrm{observer}/P}`, which is given by:
 
@@ -95,7 +275,7 @@ If we evaluate this expression in terms of the components of the orbital radius
 
     ``EXOSIMS`` adpots the convention that the observer is *below* the planet of the sky, looking up (i.e., along the positive :math:`\mathbf{\hat s}_3` direction in  :numref:`fig:orbit_diagram`).  This is different from the convention used elsewhere, and especially the convention adopted by the Exoplanet Archive, where the observer is located *above* the planet of the sky, and looking down (i.e., along the negative :math:`\mathbf{\hat e}_3` axis).  Switching conventions has no effect on the calculation of the projected separation, but does flip the sign of the phase angle, such that :math:`\cos\beta = -\sin I \sin\theta`.
 
-It is important to note that not every orbit admits the full range of possible phase angles.  As :math:`theta` always varies between 0 and :math:`2\pi` for every closed orbit, from the :ref:`equation<betacalcref>`, we see that the phase angle is bounded by the value of the inclination, such that the maximum phase angle falls within the range :math:`\left[\frac{\pi}{2} - I, \frac{\pi}{2} + I\right]`, as shown in :numref:`fig:beta_plot`.  For a face-on orbit (:math:`I = 0`), the only possible phase angle is :math:`\frac{\pi}{2}` (the observer is always at a right angle from the star-planet vector), while an edge-on orbit (:math:`I = `\frac{\pi}{2}`), admits the full range of phase angles, :math:`\beta \in [0, \pi]`.
+It is important to note that not every orbit admits the full range of possible phase angles.  As :math:`\theta` always varies between 0 and :math:`2\pi` for every closed orbit, from the :ref:`equation<betacalcref>`, we see that the phase angle is bounded by the value of the inclination, such that the maximum phase angle falls within the range :math:`\left[\frac{\pi}{2} - I, \frac{\pi}{2} + I\right]`, as shown in :numref:`fig:beta_plot`.  For a face-on orbit (:math:`I = 0`), the only possible phase angle is :math:`\frac{\pi}{2}` (the observer is always at a right angle from the star-planet vector), while an edge-on orbit (:math:`I = \frac{\pi}{2}`), admits the full range of phase angles, :math:`\beta \in [0, \pi]`.
 
 .. _fig:beta_plot:
 .. figure:: beta_plot.png
@@ -105,8 +285,72 @@ It is important to note that not every orbit admits the full range of possible p
    The range of phase angles that can occur within a given orbit are strictly bounded by the orbit's inclination. 
 
 
+
+.. _observing_bands:
+
+Observing Bands
+========================
+
+``EXOSIMS`` provides several ways to encode an observing band.  If a specific filter profile is known (i.e., from measurements of an existing filter, or if use of a standard filter is assumed), then all flux calculations can be done utilizing this profile.  Alternatively, if the filter profile is not known exactly, or if the filter definition is at a very early stage of development (i.e., you wish to evaluate a "10% band at 500 nm"), then the filter is internally described either as a box filter (characterized by bandwidth) or a Gaussian filter (characterized by its full-width at half max; FWHM). The bandwidth (:math:`\Delta\lambda`) is defined as:
+
+   .. math::
+      
+      \Delta\lambda =  \frac{1}{\max_\lambda T} \int_{-\infty}^\infty T \intd{\lambda}
+
+where :math:`T` is the wavelength-dependent transmission of the filter (see [Rieke2008]_ for details). Internally, the variable ``BW`` is typically treated as the fractional bandwidth:
+
+   .. math::
+      
+      \mathrm{BW} = \frac{ \Delta\lambda }{\lambda_0}
+
+where :math:`\lambda_0` represents the mean (central) wavelength. A Gaussian of amplitude :math:`a` has the functional form:
+
+   .. math::
+      
+      f(\lambda) = a\exp\left(-\frac{(\lambda - \lambda_0)^2}{2\sigma^2}\right)
+
+where :math:`\sigma` is the standard deviation. The full-width at half max of a Gaussian is given by:
+
+   .. math::
+      
+      \mathrm{FWHM} = 2\sqrt{2\ln(2)} \sigma
+
+and the integral of the Gaussian is:
+
+   .. math::
+
+      \int_{-\infty}^\infty a\exp\left(-\frac{(\lambda - \lambda_0)^2}{2\sigma^2}\right) \intd{\lambda} = a\sqrt{2\pi} \sigma
+
+meaning that we can relate the bandwidth and FWHM of a Gaussian filter as:
+
+   .. math::
+      
+      \Delta\lambda = a \sqrt{\frac{\pi}{\ln(2)}} \frac{\mathrm{FWHM}}{2}
+
+
+:numref:`fig:gauss_box_bandpasses` shows bandwidth-equivalent Gaussian and box filters corresponding to 10% bands at 500 nm and 1.5 :math:`\mu\mathrm{m}` (both with amplitude 1), overlaid on the G0V pickles template from :numref:`fig:pickles_bpgs_G0V`.  The fluxes computed for this spectrum using the two different filter definitions differ by much less than 1% in both cases (0.2% at 500 nm and 0.08% at 1.5 :math:`\mu\mathrm{m}`). 
+
+.. _fig:gauss_box_bandpasses:
+.. figure:: gauss_box_bandpasses.png
+   :width: 100.0%
+   :alt: Equivalent Gaussian and Box filters for 10% bands
+    
+   Equivalent bandwidth Gaussian (blue) and box (red) filters for 10% bands centered at 500 nm and 1.5 :math:`\mu\mathrm{m}` overlaid on the G0V spetrum from :numref:`fig:pickles_bpgs_G0V`. The bandpasses have amplitudes of 1 and are arbitrarily scaled for visualization purposes. 
+
+:numref:`fig:synphot_standard_bands` shows the ``synphot`` default filter profiles for the standard Johnson-Cousins/Bessel bands, which are used in the template spectra re-normalization step. 
+
+.. _fig:synphot_standard_bands:
+.. figure:: synphot_standard_bands.png
+   :width: 100.0%
+   :alt: synphot standard Johnson-Cousins/Bessel bands  
+    
+   ``synphot`` default band profiles for Johsnson-Cousins and Bessel bands.  UVB are from [Apellaniz2006]_, RI are from [Bessell1983]_, and JHK are from [Bessell1988]_.
+
+
+.. _phasefun:
+
 Phase Functions
-------------------
+========================
 
 The phase function of a planet depends on the composition of its surface and atmosphere (including any potential clouds), and can be arbitrarily difficult to model.  The simplest possible approximation to the phase function is given by the Lambert phase function, which describes a spherical, ideally isotropic, scattering body (none of which are good assumptions for planets.  The Lambert phase function is given by (see [Sobolev1975]_ for a full derivation):
 
@@ -114,17 +358,17 @@ The phase function of a planet depends on the composition of its surface and atm
 
         \pi\Phi_L(\beta) = \sin\beta + (\pi - \beta)\cos\beta
 
-While not strictly correct for any physical planet, the Lambert phase function has the benefits of being very simple to evaluate. In particular, if assuming this phase function, we can strictly bound the :math:`\Delta{\textrm{mag}}`.  Following [Brown2004]_, the flux ratio (and therefore :math:`\Delta{\textrm{mag}}`) extrema for any phase function can be found by solving for the zeros of the derivative of the flux ratio with respect to the phase angle:
+While not strictly correct for any physical planet, the Lambert phase function has the benefit of being very simple to evaluate. In particular, if assuming this phase function, we can strictly bound the :math:`\Delta{\textrm{mag}}`.  Following [Brown2004]_, the flux ratio (and therefore :math:`\Delta{\textrm{mag}}`) extrema for any phase function can be found by solving for the zeros of the derivative of the flux ratio with respect to the phase angle:
 
     .. math::
         
-        \frac{\partial}{\partial \beta} \left(\frac{F_P}{F_S}\right) = \frac{2 \Phi{\left(\beta \right)} \sin{\left(\beta \right)} \cos{\left(\beta \right)}}{s^{2}} + \frac{\sin^{2}{\left(\beta \right)} \frac{d}{d \beta} \Phi{\left(\beta \right)}}{s^{2}} = 0
+        \frac{\partial}{\partial \beta} \left(\frac{F_P}{F_S}\right) = \frac{2 \Phi{\left(\beta \right)} \sin{\left(\beta \right)} \cos{\left(\beta \right)}}{s^{2}} + \frac{\sin^{2}{\left(\beta \right)} \frac{\intd{}}{\intd{\beta}} \Phi{\left(\beta \right)}}{s^{2}} = 0
 
 where we have substituted :math:`r = s/\sin(\beta)` and assumed that both planet radius and geometric albedo are constants. This simplifies to:
 
     .. math::
         
-        2 \Phi{\left(\beta \right)} \cos{\left(\beta \right)} + \sin{\left(\beta \right)} \frac{d}{d \beta} \Phi{\left(\beta \right)} = 0
+        2 \Phi{\left(\beta \right)} \cos{\left(\beta \right)} + \sin{\left(\beta \right)} \frac{\intd{}}{\intd{\beta}} \Phi{\left(\beta \right)} = 0
 
 Substituting the Lambert phase function, we find the extrema-generating phase angle to be given by:
 
@@ -153,7 +397,7 @@ For further discussion and other phase functions built into ``EXOSIMS`` see [Kei
 
 
 Completeness, Integration Time, and :math:`\Delta{\textrm{mag}}`
-----------------------------------------------------------------------
+========================================================================
 
 Photometric and obscurational completeness, as defined originally in [Brown2005]_, is the probability of detecting a planet from some population (given that one exists), about a particular star, with a particular instrument, upon the first observation of that target (this is also known as the single-visit completeness).  Completeness is evaluated as the double integral over the joint probability density function of projected separation and :math:`\Delta{\textrm{mag}}` associated with the planet population:
 
@@ -174,7 +418,7 @@ The lower limit on :math:`\Delta\mathrm{mag}` technically depends on the assumed
 
 The integration time is typically calculated as the amount of time needed to reach a particular :term:`SNR` with some optical system for a particular :math:`\Delta\mathrm{mag}`.  We can invert this relationship (either analytically or numerically, depending on the optical system model), to compute the largest possible :math:`\Delta\mathrm{mag}` that can be achieved by our instrument on a given star for a given integration time. Since the instrument's performance typically varies with angular separation, we end up with a different :math:`\Delta\mathrm{mag}_\mathrm{max}` for every angular separation even if using a single integration time.
 
-Thus, single-visit completeness is directly a function of integration time.  The relationship is not necessarily invertible, as completeness is strictly bounded (by unity), meaning that completeness will saturate for some value of integration time.  Completeness is also not guaranteed to saturate at unity, for two possible reasons:
+Thus, single-visit completeness is directly a function of integration time.  The relationship is not always invertible, as completeness is strictly bounded (by unity), meaning that completeness will saturate for some value of integration time.  Completeness is also not guaranteed to saturate at unity, for two possible reasons:
 
 #. The projected :term:`IWA` and/or :term:`OWA` for a given star may lie within the bounds of all possible orbit geometries for the selected planet population, such that the maximum obscurational completeness is less than 1.
 #. The optical system model may include a noise floor, such that SNR stops increasing with additional integration time past some point.  In this case, :math:`\Delta\mathrm{mag}_\mathrm{max}` will saturate at the noise floor integration time, leading to a maximum photometric completeness of less than 1.
@@ -188,12 +432,136 @@ All of this is illustrated in :numref:`fig:compgrid_w_contrast`.  The heatmap sh
 
    Joint PDF of projected separation and :math:`\Delta\mathrm{mag}` with  :math:`\Delta\mathrm{mag}_\mathrm{max}` curves for various integration times.
 
-All of this can get very complicated very quickly, and all of these calculations depend on having high-fidelity models of the instrument and the numerical machinery to invert the calculation of :math:`\Delta\mathrm{mag}_\mathrm{max}` as a function of integration time.  It is typical (especially with well developed instrument models) to make the simplifying assumption (as in [Brown2005]_ and others) that :math:`\Delta\mathrm{mag}` is a constant value (sometimes called :math:`\Delta\mathrm{mag}_0` or :math:`\Delta\mathrm{mag}_\mathrm{lim}` in the literature) for all angular separations and for all targets.  In this case, the calculation of completeness is greatly simplified.  This simplification is made in ``EXOSIMS`` by default, but the full calculation is also available. 
+All of this can get very complicated very quickly, and all of these calculations depend on having high-fidelity models of the instrument and the numerical machinery to invert the calculation of :math:`\Delta\mathrm{mag}_\mathrm{max}` as a function of integration time.  It is typical (especially with instrument models that are not yet well-developed) to make the simplifying assumption (as in [Brown2005]_ and others) that :math:`\Delta\mathrm{mag}` is a constant value (sometimes called :math:`\Delta\mathrm{mag}_0` or :math:`\Delta\mathrm{mag}_\mathrm{lim}` in the literature) for all angular separations and for all targets.  In this case, the calculation of completeness is greatly simplified.  This simplification is made in ``EXOSIMS`` by default, but the full calculation is also available. 
 
 ``EXOSIMS`` actually keeps track of 3 sets of completeness, integration time, and :math:`\Delta\mathrm{mag}` values:
 
-#. The integration time and completeness corresponding to user selected :math:`\Delta\mathrm{mag}_\textrm{max}` at a particular angular separation from the target (controlled by inputs ``int_dMag`` and ``WAint`` which can be target-specific or global. This is the default integration time and completeness used in mission scheduling (or as an initial guess for further optimization of integration time allocation between targets).
-#. The :math:`\Delta\mathrm{mag}_\textrm{max}` and completeness associated with infinite integration times.  These are the saturation values described above.  In certain cases, the saturation :math:`\Delta\mathrm{mag}_\textrm{max}` may be infinite, but the saturation completeness is always strictly bounded by 1. These values are useful in comparing mission simulation results to theoretically maximum yields. 
-#. The :math:`\Delta\mathrm{mag}_\textrm{max}` and completeness associated with the maximum allowable integration time on any target by the mission rules (input variable ``intCutoff``).  In cases where the mission rules do not dictate a cutoff time, these values will be equivalent to the saturation values.  These are used to filter out target stars where no detections are likely for a particular mission setup. 
+#. The integration time and completeness corresponding to user selected :math:`\Delta\mathrm{mag}_\textrm{max}` at a particular angular separation from the target (controlled by inputs ``int_dMag`` and ``int_WA`` which can be target-specific or global. This is the default integration time and completeness used in mission scheduling (or as an initial guess for further optimization of integration time allocation between targets).
+#. The :math:`\Delta\mathrm{mag}_\textrm{max}` and completeness (stored as ``saturation_dMag`` and ``saturation_comp``, respectively) associated with infinite integration times.  These are the saturation values described above.  In certain cases, the saturation :math:`\Delta\mathrm{mag}_\textrm{max}` may be infinite, but the saturation completeness is always strictly bounded by 1. These values are useful in comparing mission simulation results to theoretically maximum yields. 
+#. The :math:`\Delta\mathrm{mag}_\textrm{max}` and completeness (stored as ``intCutoff_dMag`` and ``intCutoff_comp``, respectively) associated with the maximum allowable integration time on any target by the mission rules (input variable ``intCutoff``).  In cases where the mission rules do not dictate a cutoff time, these values will be equivalent to the saturation values.  These are used to filter out target stars where no detections are likely for a particular mission setup. 
 
 See :ref:`TargetList` for further details. 
+
+.. note::
+
+   Historically, ``EXOSIMS`` has used multiple different :math:`\Delta\textrm{mag}` values. User-supplied values for determining default integration times were previously known as ``dMagint`` and  ``WAint``.  A user-supplied ``dMagLim`` was used for evaluating single-visit completeness, and a user-supplied ``dMag0`` was utilized for computing minimum integratoin times for targets.  As of version 3.0, ``dMag0`` was eliminated entirely, and ``dMagLim`` replaced by ``intcutoff_dMag``.
+
+
+Stellar Diameter
+===================
+
+Because starlight suppression system performance may vary with stellar diameter, ``EXOSIMS`` needs to be able to track angular sizes for all targets.  Where such information is unavailable from the star catalog, we use the polynomial relationship from [Boyajian2014]_ (specifically for V-band magnitudes and B-V colors), which has the form:
+
+    .. math::
+        
+        \log_{10}\left(\frac{\theta_\mathrm{LD}}{1 \textrm{ mas}}\right) = \sum_{i=0}^4 a_i (\textrm{B-V})^i - 0.2 m_V
+
+where :math:`\theta_\mathrm{LD}` is the angular diameter of the star, corrected for limb-darkening, defined as in [HanburyBrown1974]_, and :math:`a_i` are the fit coefficients:
+
+    .. math::
+    
+        a_{0\ldots4} = 0.49612, 1.11136, -1.18694, 0.91974, -0.19526
+
+We can validate this model in two ways.  As a preliminary check, we look at some of the data used for the original model fit.   [Boyajian2013]_, table 2, provides the measured angular diameters for 23 stars, 18 of which overlap with targets in the EXOCAT1 star catalog.  For these 18 targets, we compare the model results (using the catalog's B-V and :math:`m_V` values) to the measurements from the paper, with results in :numref:`fig:boyajian_model_v_meas`.
+
+.. _fig:boyajian_model_v_meas:
+.. figure:: boyajian_model_v_meas.png
+   :width: 100.0%
+   :alt: Stellar diameter model vs measurements 
+
+   Measured stellar diameters (from [Boyajian2013]_) vs. modeled diameters (using model from [Boyajian2014]_) for 18 EXOCAT1 targets.  The dashed black line has slope=1 for reference.
+
+The measurements and model have excellent agreement, with average errors below 5%. As an additional check, we consider the stellar radius predicted by the Stefan-Boltzmann law:
+
+    .. math::
+    
+        R_\star = \sqrt{\frac{L_\star}{4\pi \sigma_{SB} T_\mathrm{eff}^4}}
+
+where :math:`L_\star` is the star's bolometric luminosity as :math:`\sigma_{SB}` is the Stefan-Boltzmann constant, with a value of 5.67 :math:`\times 10^{-8}` W m :math:`^{-2}` K :math:`^{-4}`.  Again relying on the [Ballesteros2012]_ model for effective temperature (see :ref:`modelingir`), we can compute the stellar radii (taking 1 solar luminosity to be 3.828 :math:`\times 10^{26}` W as per IAU 2015 Resolution B3) and then convert them to stellar angular diameters as:
+
+    .. math::
+    
+        \theta = 2\tan^{-1}\left(\frac{R_\star}{d}\right)
+
+We compare this calculation with the [Boyajian2014]_ model for all targets in EXOCAT1, with the results shown in :numref:`fig:boyajian_model_v_stefan-boltzmann`.  The two calculations have excellent agreement, with mean errors of about 7.55%, despite the very large assumptions being made in the use of the Stefan-Boltzmann law. 
+
+.. _fig:boyajian_model_v_stefan-boltzmann:
+.. figure:: boyajian_model_v_stefan-boltzmann.png
+   :width: 100.0%
+   :alt: Stellar diameter model comparison
+
+   Stellar diameters computed from Stefan-Boltzmann law vs. modeled as in [Boyajian2014]_ for 2193 stars.  The dashed black line has slope=1 for reference.
+
+.. _zodiandexozodi:
+
+Zodiacal and Exozodiacal Light
+=================================
+
+The local zodiacal light represents an important background noise source for all imaging observations, and one of the few that we have any control over when scheduling observations (as the light intensity depends on the orientation of the look vector with respect to the sun). Following [Stark2014]_ and [Stark2015]_, ``EXOSIMS`` uses tabulated data from [Leinert1998]_ (specifically Tables 17 and 19) to model the wavelength- and orientation-dependent variation in the zodiacal light. :numref:`fig:zodi_intensity_leinert17` shows the data from [Leinert1998]_ Table 17, linearly interpolated in solar ecliptic longitude (:math:`\Delta\lambda_\odot`) and ecliptic latitude (:math:`\beta_\odot`) corresponding to the target look vector (i.e., observatory to target line of sigh unit vector) and converted to units of  :math:`\textrm{ photons m}^{-2}\textrm{ s}^{-1}\, \textrm{nm}^{-1}  \textrm{as}^{-2}`` (cf. [Leinert1998]_ Fig. 37 and [Keithly2020]_ Fig. 6). This represents the zodiacal light specific intensity at a wavelength of 500 nm. 
+
+.. _fig:zodi_intensity_leinert17:
+.. figure:: zodi_intensity_leinert17.png
+   :width: 100.0%
+   :alt: Zodiacal light intensity
+
+   Variation in Zodiacal light specific intensity with look vector orientation.  Data from [Leinert1998]_, Table 17.
+
+
+.. _fig:zodi_color_leinert19:
+.. figure:: zodi_color_leinert19.png
+   :width: 100.0%
+   :alt: Zodiacal light wavelength dependence
+
+   Variation in Zodiacal light specific intensity with wavelength.  Data from [Leinert1998]_, Table 19.
+
+:numref:`fig:zodi_color_leinert19` shows the data from [Leinert1998]_ Table 19, converted to units of  :math:`\textrm{ photons m}^{-2}\textrm{ s}^{-1}\, \textrm{nm}^{-1}  \textrm{as}^{-2}`` along with a quadratic interpolant in log space (cf. [Leinert1998]_ Figs. 1 and 38 and [Keithly2020]_ Fig. 9). This represents the  zodiacal light specific intensity at an ecliptic latitude of 0 and solar ecliptic longitude of :math:`90^\circ` as a function of wavelength. 
+
+We define the interpolant from :numref:`fig:zodi_intensity_leinert17` as :math:`I^V_{\textrm{zodi},\mf r}(\Delta\lambda_\odot, \beta_\odot)` and the interpolant from :numref:`fig:zodi_color_leinert19` as :math:`I_{\textrm{zodi},\lambda}`.  Together, they allow us to compute the specific intensity of the local zodiacal light for a given observation as:
+
+
+    .. math::
+    
+        I_\textrm{zodi}(\Delta\lambda_\odot, \beta_\odot, \lambda_0) = I_{\textrm{zodi},\mf r}(\Delta\lambda_\odot, \beta_\odot)\frac{I_{\textrm{zodi},\lambda}(\lambda_0)}{I_{\textrm{zodi},\lambda}(500\textrm{ nm})}
+
+Exozodiacal light is treated much in the same way as the local zodiacal light, save that we allow for a variable number of exozodi, encoded by :math:`n_\textrm{zodi}`, which is defined as in Appendix C of [Stark2014]_. The exozodiacal light specific intensity in V-band is evaluated as in equation (C4) of that work:
+
+    .. math::
+
+        I^V_\textrm{exozodi} = n_\textrm{zodi} \mc F_{0,V} 10^{-0.4(M_V- M_{V,\odot}+x)}\left(\frac{1\textrm{ AU}}{r}\right)^2
+
+where :math:`\mc F_{0,V}` is the V-band zero-magnitude flux density, :math:`M_V` and :math:`M_{V,\odot}` are the absolute magnitudes of the target star and the sun, respectively, :math:`x` is the nominal specific brightness of the disk at 1 AU (22 mag :math:`\textrm{arcsec}^{-2}`), and :math:`r` is the magnitude of the planet's orbital radius vector at the time of the observation (see: :ref:`orbgeom`).
+
+We use the same Table 19 interpolant from [Leinert1998]_ to find the value in our arbitrary observing band:
+
+    .. math::
+
+        I_\textrm{exozodi}(r, \lambda_0) = I^V_{\textrm{exozodi}}(r)\frac{I_{\textrm{zodi},\lambda}(\lambda_0)}{I_{\textrm{zodi},\lambda}(500\textrm{ nm})}
+
+Finally, we may also wish to account for the impact of the inclination of the target system on the exozodiacal light brightness. Here we have several options: We can use the empirical relationship of the local zodi's latitudinal variation from the TPF planner model by Don Lindler (2006), which was published as equation 16 of [Savransky2010]_. This has the form:
+
+    .. math::
+
+        f(\theta) = 2.44 − 0.0403\left(\frac{\theta}{1\textrm{ deg}}\right) + 0.000269 3\left(\frac{\theta^2}{1\textrm{ deg}^2}\right) 
+
+where :math:`\theta \triangleq \vert 90^\circ - I\vert` and :math:`I` is the orbital inclination of the target planet (:math:`\theta \equiv \vert\beta_\odot\vert` for local zodi variations). We compare this against a similar relationship derived in [Stark2014]_ (equation B4) by fitting to the [Leinert1998]_ Table 17 data (at :math:`\Delta\lambda_\odot = 135^\circ`, where the local zodi approaches its minimum values). This has the form:
+
+.. math::
+
+    f(\theta) = 1.02 - 0.566 \sin\theta - 0.884 \sin^2\theta + 0.853 \sin^3\theta
+        
+It is important to note that the former fit is normalized at :math:`\theta = 90^\circ` while the latter is normalized at :math:`\theta = 0^\circ`. Finally, we can compare these to direct interpolants of the [Leinert1998]_ Table 17 at :math:`\Delta\lambda_\odot = 135^\circ` and :math:`90^\circ`.
+
+.. _fig:zodi_latitudinal_variation:
+.. figure:: zodi_latitudinal_variation.png
+   :width: 100.0%
+   :alt: Zodiacal light latitudinal variation models
+
+   Different models of variation in Zodiacal light with viewing angle.
+
+:numref:`fig:zodi_latitudinal_variation` shows a comparison of the two models (with the Lindler model re-normalized at :math:`\theta = 0`) and the two interpolants.  All of these, except for the interpolant at :math:`\Delta\lambda_\odot = 90^\circ` have quite good agreement, and so we choose the Table interpolant at :math:`\Delta\lambda_\odot = 135^\circ` as our default. 
+
+.. important::
+
+    While intensity ratios are all unitless, they will have different values at various wavelengths if evaluated in power units vs. photon units.  Therefore, the units of the intensities used to compute the ratios must match those of the intensity being scaled.  By default, ``EXOSIMS`` operates in the original units of the data (power units in the case of the [Leinert1998]_ tables) and converts to photon units as a final step, when needed. 
+
+Scaling the specific intensity values by the optical system's field of view gives the spectral flux densities of the zodiacal and exozodiacal light. For more in-depth discussion, see [Keithly2020]_ and :ref:`ZodiacalLight`.
diff --git a/documentation/conf.py b/documentation/conf.py
index 537950eec..10a6e0336 100644
--- a/documentation/conf.py
+++ b/documentation/conf.py
@@ -39,6 +39,7 @@
     'sphinx.ext.ifconfig',
     'sphinx.ext.viewcode',
     'sphinx.ext.intersphinx',
+    'sphinxcontrib.mermaid'
 ]
 
 # Add any paths that contain templates here, relative to this directory.
@@ -57,7 +58,7 @@
 
 # General information about the project.
 project = u'EXOSIMS'
-copyright = u'2015 - 2022, SIOSlab'
+copyright = u'2015 - 2023, SIOSlab'
 author = u'SIOSlab'
 
 # The version info for the project you're documenting, acts as replacement for
@@ -140,6 +141,7 @@
 #                      'logo': 'logo.png',
 #                      'sidebar_collapse':'true'
 #                      }
+html_theme_options = {'collapse_navigation': False}
 
 # Add any paths that contain custom themes here, relative to this directory.
 #html_theme_path = []
@@ -317,8 +319,10 @@
                        'astropy': ('https://docs.astropy.org/en/stable/', None),
                        'numpy': ('https://numpy.org/doc/stable/', None),
                        'scipy': ('https://docs.scipy.org/doc/scipy/', None),
-                       'matplotlib': ('https://matplotlib.org/stable/', None)}
+                       'matplotlib': ('https://matplotlib.org/stable/', None),
+                       'synphot': ('https://synphot.readthedocs.io/en/latest/', None)}
 
+# add latex customization
 mathjax3_config = {
     'tex':{
         'macros':{
@@ -328,3 +332,18 @@
         }
     }
 }
+
+latex_elements = {
+    'preamble': r"""
+\def\bs{\boldsymbol}
+\def\mf{\mathbf}
+\def\mb{\mathbb}
+\def\mc{\mathcal}
+\newcommand{\intd}[1]{\ensuremath{\,\mathrm{d}#1}}
+\newcommand{\leftexp}[2]{{\vphantom{#2}}^{#1}\!{#2}}
+\newcommand{\leftsub}[2]{{\vphantom{#2}}_{#1}\!{#2}}
+""",
+}
+
+mermaid_params = ['-p' 'puppeteer-config.json']
+mermaid_version = "9.4.0" # temporary until next sphinxcontrib.mermaid release
diff --git a/documentation/doc_requirements.txt b/documentation/doc_requirements.txt
new file mode 100644
index 000000000..6499238a9
--- /dev/null
+++ b/documentation/doc_requirements.txt
@@ -0,0 +1,5 @@
+-r ../requirements.txt
+
+Sphinx>=4.0
+sphinx-rtd-theme>=1.0.0
+sphinxcontrib-mermaid
diff --git a/documentation/docs.rst b/documentation/docs.rst
index 618cea818..bb50dbffd 100644
--- a/documentation/docs.rst
+++ b/documentation/docs.rst
@@ -10,12 +10,12 @@ Installing Sphinx
 ------------------
 
 You will need the `sphinx documentation tool <http://sphinx-doc.org>`_ and 
-its `numpydoc extension <https://pypi.python.org/pypi/numpydoc>`_ and `napoleon extension <https://pypi.python.org/pypi/sphinxcontrib-napoleon>`_, along with the readthedocs theme.
+its `numpydoc extension <https://pypi.python.org/pypi/numpydoc>`_, `napoleon extension <https://pypi.python.org/pypi/sphinxcontrib-napoleon>`_, and `mermaid extension <https://pypi.org/project/sphinxcontrib-mermaid/>`_, along with the readthedocs theme.
 
 Assuming you have the `pip <http://www.pip-installer.org/en/latest/installing.html>`_ python package installer, 
 you can easily install the required tools from `PyPI <https://pypi.python.org/pypi>`_::
 
-   >>> pip install Sphinx, numpydoc, sphinxcontrib-napoleon, sphinx_rtd_theme
+   >>> pip install Sphinx, numpydoc, sphinxcontrib-napoleon, sphinxcontrib-mermaid, sphinx_rtd_theme
 
 Building the docs
 ------------------
diff --git a/documentation/gauss_box_bandpasses.png b/documentation/gauss_box_bandpasses.png
new file mode 100644
index 000000000..4fb943736
Binary files /dev/null and b/documentation/gauss_box_bandpasses.png differ
diff --git a/documentation/glossary.rst b/documentation/glossary.rst
index e87bdbd84..af62e1062 100644
--- a/documentation/glossary.rst
+++ b/documentation/glossary.rst
@@ -37,6 +37,15 @@ Glossary
     phase angle
         The illuminant-object-observer angle.  The angle between the planet-star and planet-observer vectors. 
 
+    QE
+        Quantum Efficiency. The ratio of the number of charge carriers collected at the readout terminal to the number of photons hitting the detector surface.
+
+    FWHM
+        Full-Width at Half-Maximum
+
+    PSF
+        Point spread function.  The impulse response of an optical system. 
+
 
 
 
diff --git a/documentation/offaxpsf_thruput_anim.gif b/documentation/offaxpsf_thruput_anim.gif
new file mode 100644
index 000000000..7e35a7204
Binary files /dev/null and b/documentation/offaxpsf_thruput_anim.gif differ
diff --git a/documentation/opticalsystem.rst b/documentation/opticalsystem.rst
index 561414686..6b6ca8b57 100644
--- a/documentation/opticalsystem.rst
+++ b/documentation/opticalsystem.rst
@@ -8,123 +8,265 @@ Optical system modules describe the science instrument and starlight suppression
 Optical System Definition
 ----------------------------
 
-An optical system is defined by three objects:
+An optical system is defined by the effective collecting area and three sets of objects:
 
 * Science Instrument(s)
 * Starlight Suppression System(s)
 * Observing Mode(s)
 
-Each of these is encoded as a list of dictionaries with a minimum size of 1.  A science instrument is a description of a detector and any associated optics not belonging to the starlight suppression system.  A science instrument must be classified as an imager (no spectral resolution) or a spectrometer (finite spectral resolution). A starlight suppression system is a description of all of the optics responsible for producing regions of high contrast.  It must be classified as a coronagraph (internal) or occulter (starshade; external). Finally, an observing mode is the combination of a starlight suppression system and a science instrument, along with a target :term:`SNR` for all integrations performed with that observing mode.  
+Each of these is encoded as a list of dictionaries with a minimum size of 1.  A science instrument is a description of a detector and any associated optics not belonging to the starlight suppression system.  A science instrument must be classified as an imager (no spectral resolution) or a spectrometer (finite spectral resolution). A starlight suppression system is a description of all of the optics responsible for producing regions of high contrast.  It must be classified as a coronagraph (internal) or occulter (starshade; external). Finally, an observing mode is the combination of a starlight suppression system and a science instrument, along with a target :term:`SNR` for all integrations performed with that observing mode. 
 
-Many quantities defining the optical system must be parametrizable by wavelength or angular separation (or both).  In cases where only a single value exists at your current design stage, you must still structure these aspects of the system as callable (just returning the same value regardless of input).  As an example, quantum efficiency is a function of wavelength for nearly all physical devices.  If you have not yet selected a specific device (or do not happen to have the QE curve for the device you are modeling) your QE parameter should be a callable method or lambda function that always returns the same constant QE you wish to use in your current model.
+The effective collecting area (:math:`A`) is the area of the primary mirror, minus all obscurations due to the secondary (and any other obscuring optics) as well as their support structures.  It is defined via three inputs:
 
-In general, each dictionary describing each of these objects can have essentially any keywords. This description allows for optical system definitions to be highly flexible and extensible, but can also lead to inescapable complexity.  To attempt to make the code more parsable, a few conventions are maintained, as outlined below.
+* The primary mirror diameter (:math:`D`) - in cases of non-circular mirrors, this is the major diameter
+* The obscuration factor (:math:`F_o`) - the fraction of the primary mirror area that is obscured
+* The shape factor (:math:`F_s`) - defined such that the total primary mirror area is given by :math:`F_sD^2`. That is, for a circular mirror :math:`F_s = \pi/4`
 
+Given these quantities, the effective collecting area is computed as:
+
+.. math::
+
+    A  = (1 - F_o)F_sD^2
+
+Many quantities defining the optical system must be parametrizable by wavelength or angular separation (or both, or other quantities).  In cases where only a single value exists at your current design stage, these must still be structured as callable (just returning the same value regardless of input).  As an example, quantum efficiency is a function of wavelength for nearly all physical devices.  If you have not yet selected a specific device (or do not happen to have the QE curve for the device you are modeling) your QE input can be a scalar value, but will be automatically wrapped in a lambda function that always returns your constant QE value for any inputs.
+
+In general, each dictionary describing each of these objects can have essentially any keywords. This description allows for optical system definitions to be highly flexible and extensible, but can also lead to inescapable complexity.  To attempt to make the code more parsable, a few conventions are maintained, as outlined below. For all three dictionary types (``scienceInstrument``, ``starlightSuppressionSystem``, and ``observingMode``), missing required values will be filled from defaults, which are all inputs to the prototype OpticalSystem ``__init__`` and may be modified in the :ref:`sec:inputspec`. The utility of this approach is that values that are expected to be the same for all instances of a dictionary (for example the number of detector pixels for all instruments using the same detector) can be set just once via the default, rather than needing to be set in each instrument definition.  
+
+.. _scienceinstrument:
 
 Science Instruments
-""""""""""""""""""""
+^^^^^^^^^^^^^^^^^^^^^^
 
 Each ``scienceInstrument`` dictionary must contain a unique ``name`` keyword.  This string must include a substring of the form ``imager`` or ``spectro``. For example, an optical system might contain science instruments called ``imager-EMCCD`` or ``spectro-CCD``, describing a photon counting electron multiplying CCD imager and a mid-resolution imaging spectrometer.  In cases where the same physical detector hardware is expected to be used in different modes (i.e., a single chip serving as an imager and polarizer, and integral field unit by the introduction of additional removable optics), you must still set up separate science instruments for each operating mode.
 
-Common science instrument attributes include:
+Required Prototype Instrument Parameters
+""""""""""""""""""""""""""""""""""""""""""
+These values will be set from defaults if missing in the instrument dictionary.  Defaults for all instruments can be set as top-level values in the :ref:`sec:inputspec`. These top-level values also carry their own default values in the prototype implementation. 
 
 * name (string):
-    Instrument name (e.g. imager-EMCCD, spectro-CCD). Must contain the type of
-    instrument (imager or spectro). Every instrument should have a unique name.
+    Instrument name (e.g. imager-EMCCD, spectro-CCD). Must contain the type of instrument ('imager' or 'spectro'). Every instrument must have a unique name.
 * QE (callable):
-    Detector quantum efficiency, parametrized by wavelength.
+    Detector quantum efficiency, parametrized by wavelength. The input value can be a scalar (fixed QE for all wavelengths) or a the full path to a file with the QE profile.  The file may be a FITS file or a CSV file.  FITS files must contain two rows (or columns) where the first represents wavelength and the second represents QE. The FITS header should specify the wavelength unit in keyword ``UNITS`` which should contain a string that is parsable as an astropy length unit (https://docs.astropy.org/en/stable/units/format.html). If the keyword does not exist, units of nm will be assumed. For CSV files, the wavelength should be in nm in a column with header ``lambda`` and the QE must be in a column with header ``QE``.  There is not required order for the two columns in a CSV file. The top-level default defaults to 0.9.
 * optics (float):
-    Attenuation due to optics specific to the science instrument
-* FoV (Quantity):
-    Field of view in angle units
-* pixelNumber (int):
-    Detector array format, number of pixels per detector lines/columns
-* pixelScale (Quantity):
-    Detector pixel scale in units of angle per pixel
-* pixelSize (Quantity):
-    Pixel pitch in units of length
-* focal (Quantity):
-    Focal length in units of length
-* fnumber (float):
-    Detector f-number
+    Attenuation due to optics specific to the science instrument. The top-level default defaults to 0.5. 
 * sread (float):
-    Detector effective read noise per frame per pixel
+    Detector effective read noise per frame per pixel. The top-level default defaults to 1e-6.
 * idark (Quantity):
-    Detector dark-current per pixel in units of 1/Time
-* CIC (float):
-    Clock-induced-charge per frame per pixel
+    Detector dark-current per pixel in units of 1/time. Input values are assumed to have units of 1/second. The top-level default defaults to 1e-4.
 * texp (Quantity):
-    Exposure time per frame in units of Time
+    Exposure time per frame in units of Time. Input values are assumed to have units of seconds. The top-level default defaults to 100.
+* Rs (float):
+    (Specific to spectrometers) Spectral resolving power: :math:`\lambda/\Delta\lambda`. The top-level default defaults to 50.
+* lenslSamp (float):
+    (Specific to spectrometers) Lenslet sampling: number of pixels per lenslet row (or column). The top-level default defaults to 2.
+* pixelNumber (int):
+    Detector array format, number of pixels per detector line/column. The top-level default defaults to 1000.
+* pixelSize (Quantity):
+    Pixel pitch in units of length.  Input values are assumed to have units of meters. The top-level default defaults to 1e-5 (10 microns). 
+* FoV (Quantity):
+    Angular half-field of view (i.e., field of view radius). Input values are assumed to have units of arcseconds.  The ``FoV`` value is used only for determining the maximum outer working angle in observing modes where the starlight suppression system has an infinite :term:`OWA` (or an OWA larger than the science instrument FoV). The top-level default defaults to 10.
+* pixelScale (Quantity):
+    Pixel scale (instantaneous field of view of each detector pixel). Input values are assumed to have units of arcseconds. The top-level default defaults to 0.02.
+
+Field of View and Pixel Scale
+""""""""""""""""""""""""""""""
+
+Naively, the field of view and pixel scale should be related to one another. Assuming that the detector size is given by :math:`n_\textrm{pix} \times d_\textrm{pix}`, where :math:`n_\textrm{pix}` and :math:`d_\textrm{pix}`  are the ``pixelNumber`` and ``pixelSize`` values, respectively, then we can write:
+
+   .. math::
+      
+      \textrm{FoV} &=  2 \tan^{-1}{\left(\frac{n_\textrm{pix} d_\textrm{pix}}{2f}\right)} \\
+      \textrm{pixelScale} &=  2 \tan^{-1}{\left(\frac{ d_\textrm{pix}}{2f}\right)} 
+
+where :math:`f` is the focal length. Note that the FoV term in these equations is the full field of view (twice the ``FoV`` parameter). From this, we can relate the two as:
+
+   .. math::
+      
+      \textrm{FoV} =  2 \tan^{-1}{\left(n_\textrm{pix} \tan{\left(\frac{\textrm{pixelScale}}{2} \right)} \right)}
+    
+
+However, the detector does not necessarily set the field of view of the imaging system (especially for some coronagraphic systems) and so these must be left as independent inputs. An example of real systems where the pixel scale and field of view are and are *not* linked are the Wide-Field Instrument (WFI) and Coronagraph Instrument (CGI) on the Roman Space Telescope, respectively (see: https://roman.ipac.caltech.edu/sims/Param_db.html).  In the case of the WFI, the detectors set the field of view, whereas for the CGI, the field of view is limited by vignetting due to an aperture stop.
+
+The field of view should always be less than or equal to the one predicted by the pixel scale. A warning will be generated if the input ``FoV`` is larger than the one computed from the ``pixelScale``, but no errors will be raised (the warning is suppressed in cases where the two quantities are approximately equal to account for numerical errors). 
+
+.. warning::
+
+    If using a starshade and setting the ``pixelScale`` as a top-level default or per-instrument input, it is crucial to also set the field of view to its appropriate value (otherwise the original default will be used, which may be inconsistent with the input ``pixelScale``). 
+
+Focal Length and f-Number
+""""""""""""""""""""""""""
+
+The instrument effective focal length (encoded in instrument parameter ``focal``) can be specified either directly as a per-instrument value (in which case inputs are assumed to have units of meters) or as a per-instrument f-number input (encoded in instrument parameter ``fnumber``).  If both are given, the ``fnumber`` input is ignored and the value re-computed from the ``focal`` input.  If neither input is given, both are computed from the ``pixelScale`` and ``pixelSize`` as:
+
+   .. math::
+      
+      f =  \frac{d_\textrm{pix}}{2 \tan\left(\textrm{pixelScale}/2\right)}
+
+A warning will be generated if the input focal length (or equivalently f-number) do not approximately match this expression, but not errors will be raised. 
+
+Photon Counting Detectors
+""""""""""""""""""""""""""
+
+The prototype detector model does not account for photon-counting detectors and their particular noise sources.  This is handled by implementation :py:class:`~EXOSIMS.OpticalSystem.Nemati`, which takes the following additional inputs (either as top-level defaults or per-instrument values):
+
+* CIC (float):
+    Clock-induced-charge per frame per pixel.
 * radDos (float):
     Radiation dosage. Use of this quantity is highly specific to your particular optical system model.
 * PCeff (float):
     Photon counting efficiency
 * ENF (float):
-    (Specific to EM-CCDs) Excess noise factor
-* Rs (float):
-    (Specific to spectrometers) Spectral resolving power
-* lenslSamp (float):
-    (Specific to spectrometers) Lenslet sampling, number of pixel per
-    lenslet rows or cols
+    (Specific to EM-CCDs) Excess noise factor.
 
+.. _starlightsuppressionsystem:
 
 Starlight Suppression System
-""""""""""""""""""""""""""""""
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Each ``starlighSuppressionSystem`` dictionary must contain a unique ``name`` keyword identifying the starlight suppression system (coronagraph or occulter). There are no special requirements for this string (unlike the ``name`` in the :ref:`scienceinstrument` dictionary).  As with the science instruments, if you are modeling a reconfigurable coronagraph (i.e., multiple filter wheels with multiple masks) you must define a separate system for each unique configuration you wish to model. Occulters operating at multiple distances must also be set up this way.
+
 
-Each ``starlighSuppressionSystem`` dictionary must contain a unique name identifying the starlight suppression system (coronagraph or occulter).  As with the science instruments, if you are modeling a reconfigurable coronagraph (i.e., multiple filter wheels with multiple masks) you must define a separate system for each unique configuration you wish to model. Occulters operating at multiple distances must also be set up this way.
+Required Prototype Starlight Suppression System Parameters
+"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
 
-Common starlight suppression system attributes include:
+These will be set from defaults if missing.  Defaults for all systems for some parameters can be set as top-level values in the :ref:`sec:inputspec`.
 
 * name (string):
-    System name (e.g. HLC-565, SPC-660), should also contain the
-    central wavelength the system is optimized for. Every system must have
+    System name (e.g. HLC-565, SPC-660).  By convention, this should also contain the
+    central wavelength the system is optimized for (but this is not a requirement). Every system must have
     a unique name.
 * optics (float):
-    Attenuation due to optics specific to the coronagraph,
-    e.g. polarizer, Lyot stop, extra fold mirrors, etc.
+    Attenuation due to optics specific to the coronagraph, but not captured in the various throughput values (see below). 
+    This value cannot be set as a top-level default and must be a per-system value.  If missing, it defaults to 1 (no additional attenuation). 
+
+.. important:: 
+
+   Although they have the same name and purpose, the ``optics`` kewyord in the starlight suppression system is different from the one in the science instrument, and is not set from the top-level default.  The ``optics`` value described here can only be set on a per-system basis. 
+
 * lam (Quantity):
-    Central wavelength in units of length
+    Central wavelength in units of length. Input values are assumed to be in nm. The top-level default defaults to 500.
 * deltaLam (Quantity):
-    Bandwidth in units of length
+    Bandwidth in units of length. Input values are assumed to be in nm. This quantity has no top-level default and can only be set on a per-system basis. 
 * BW (float):
-    Bandwidth fraction
+    Bandwidth fraction, such that :math:`\lambda \times \textrm{BW} = \Delta\lambda`. When present, ``deltaLam`` is used preferentially. The top-level default defaults to 0.2 (20% band). 
+* ohTime (Quantity):
+    Overhead time for all integrations. Inputs are assumed to be in units of days. The top-level default defaults to 1.
+* occulter (boolean):
+    True if the system has an occulter (external or hybrid system) otherwise False (internal system). All systems have this key set to False by default.  This key has no user-settable top-level default. 
+* contrast_floor (float, optional):
+    An absolute limit on achievable core_contrast. Only scalar inputs (or None) are supported at this time. The top-level default defaults to None.
 * IWA (Quantity):
-    Inner working angle in units of arcsec
+    Inner working angle. Input values are assumed to be in units of arcsec. The top-level default defaults to 0.1.
 * OWA (Quantity):
-    Outer working angle in units of arcsec
+    Outer working angle. Input values are assumed to be in units of arcsec. Zero values are interpreted as infinity. Note that Python JSON also understands ``Infinity`` entries.  The top-level default defaults to Infinity.
+
+.. warning:: 
+
+   Any IWA/OWA values set as parameters of a starlight suppression system (or from top-level default values) will be overwritten if they disagree with angular separation ranges in table data used for other parameters.  That is, if the data file used for ``occ_trans`` or ``core_thruput``, etc., has a smallest angular separation that is larger than the currently set ``IWA`` or a largest separation that is smaller than the current system ``OWA``, then the values will be updated to match the table data.  A warning (but not error) will be generated when this happens. If no IWA/OWA inputs are supplied then both values will be set from the first data table read.
+
+* input_angle_units (str, optional):
+    The angle unit to assume for all starlight suppression system inputs that are angles (or are parametrized by an angular value).  This also applies to data in CSV files (see below for details) and FITS files (if no superseding keyword is found in the FITS header).  If None or ``LAMBDA/D`` or ``unitless`` then all angles are interpreted as :math:`\lambda/D` values. Otherwise, this string must be parsable as an astropy length unit (see: https://docs.astropy.org/en/stable/units/format.html). The top-level default defaults to 'arcsec'. The ``input_angle_units`` value is used to compute the Quantity ``syst["input_angle_unit_value"]`` which is the value of the input angle unit in physical angle units (for :math:`\lambda/D` inputs this uses the wavelength in ``syst["lam"]``). 
+* core_platescale_units (str, optional):
+  The angle unit to assume for ``core_platescale``.  Behaves exactly in the same way as ``input_angle_units`` but defaults to None (i.e., :math:`\lambda/D` units).
+* core_platescale (float or None):
+    The pixel scale (angular extent of each pixel) for the coronagraph PSF and intensity maps. Scalar inputs are assumed to have units of ``core_platescale_units``.  This must be set if using ``core_mean_intensity`` and setting a scalar input or using a CSV file. If reading from a FITS file, the value can be encoded in the header (see below). If this value is not set at the end of populating a ``starlightSuppressionSystem`` dictionary and ``core_mean_intensity`` is not None, an error will be thrown. The top-level default defaults to None.
+
+.. important::
+
+   It is crucial to differentiate between the :ref:`scienceinstrument` ``pixelScale`` and the starlight suppression system ``core_platescale``.  While these might be the same value, frequently they are **not** as coronagraphs may be designed independently of the selection of the final focal-plane array. Both of these values are used to determine the number of pixels in the photometric aperture, but these pixels represent two different things: The ``pixelScale`` tells you the number of detector pixels (needed for computing detector-level things like dark current and read noise).  The ``core_platescale`` tell you the number of the pixels that the ``core_mean_itensity`` was computed from, which allows you to compute the total residual starlight intensity (internally called ``core_intensity``) in the photometric aperture. 
+
+.. note::
+
+    Why do we have two separate unit inputs (one for ``core_platescale`` and one for everything else)? The answer, unsurprisingly, is lazy backwards compatibility.  Originaly EXOSIMS forced users to convert everything to arcseconds externally to the program, *except* (confusingly) for the coronagraphic map platescale.  To avoid breaking existing input scripts, the defaults for these inputs retain this behavior (but now allow the units to be specified however the user wishes). 
+
+* csv_angsep_colname (str):
+    Column name to use for the angular separation column for CSV data for all files. This should be correlated with the ``input_angle_units`` value (assuming your CSV files have column names that specify units). The top-level default defaults to "r_as".
 * occ_trans (callable):
-    Intensity transmission of extended background sources such as zodiacal light, parametrized by angular separation.
-    Includes the pupil mask, occulter, Lyot stop and polarizer.
+    Intensity transmission of extended background sources such as zodiacal light, parametrized by wavelength and angular separation. This includes the pupil mask, occulter, Lyot stop and polarizer. Input values may be scalars or full paths to files containing input data to an interpolant. The top-level default defaults to 0.2.     
 * core_thruput (callable):
-    System throughput in the FWHM region of the planet PSF core, parametrized by angular separation.
+    System throughput in a given photometric aperture (possibly corresponding to the FWHM, set by the ``core_area`` value) of the planet PSF core, parametrized by wavelength and angular separation.  Input values may be scalars or full paths to files containing input data to an interpolant. The top-level default defaults to 0.1.
+* core_area (callable):
+    Area of the photometric aperture used to compute ``core_thruput`` and ``core_mean_itensity``, parametrized by wavelength and angular separation. Input values may be scalars or full paths to files containing input data to an interpolant. Input scalar values are assumed to have units of ``input_angle_units``. Outputs will be Quantities with units of :math:`\textrm{arcsec}^2` This key has no user-settable top-level default.  Missing core areas will be set as :math:`\pi/2\, (\lambda/D)^2` (the area of an aperture with radius :math:`\sqrt{2}/2\, \lambda/D`).
 * core_contrast (callable):
-    System contrast = mean_intensity / PSF_peak, parametrized by angular separation.
-* contrast_floor (float):
-    An absolute limit on achievable core_contrast.
+    System contrast = mean_intensity / PSF_peak, parametrized by wavelength and angular separation. Input values may be scalars or full paths to files containing input data to an interpolant. Default values are only populated in cases where ``core_mean_intensity`` is None. The top-level default defaults to 1e-10.
 * core_mean_intensity (callable):
-    Mean starlight residual normalized intensity per pixel, required to calculate
-    the total core intensity as core_mean_intensity * Npix. If not specified,
-    then the total core intensity is equal to core_contrast * core_thruput. Parametrized by angular separation.
-* core_area (callable):
-    Area of the FWHM region of the planet PSF, in units of arcsec^2, parametrized by angular separation.
-* core_platescale (float):
-    Platescale used for a specific set of coronagraph parameters, in units
-    of lambda/D per pixel
-* PSF (callable):
-    Point spread function - 2D ndarray of values, normalized to 1 at
-    the core. Note: normalization means that all throughput effects
-    must be contained in the throughput attribute. Parametrized by angular separation.
-* ohTime (Quantity):
-    Overhead time for all integrations. 
-* occulter (boolean):
-    True if the system has an occulter (external or hybrid system) otherwise False (internal system)
-* occulterDiameter (Quantity):
-    Occulter diameter in units of m. Measured petal tip-to-tip.
-* occulterDistance (Quantity):
-    Telescope-occulter separation in units of km.
+    Mean starlight residual normalized intensity per map pixel (i.e., per pixel of the simulated PSF).  The total core intensity is computed as ``core_mean_intensity`` times the number of intensity map pixels in the photometric aperture (the number of pixels is determined from the ``core_platescale`` and ``core_area`` values). If ``core_mean_intensity`` is not specified, then the total core intensity is computed as ``core_contrast`` * ``core_thruput``. This value is parametrized by wavelength, angular separation, and angular star diameter. The diameter value defaults to 0 arcseconds (unresolved target star). If a scalar value (or CSV file, or FITS file without the relevant header keyword) is used, a ``core_platescale`` input *must* be set. This key has no user-settable top-level default and can only be set on a per-system basis.
+
+.. important::
+
+    The top-level default value will only be populated for ``core_contrast`` in the case where no ``core_mean_intensity`` is provided in the starlightSuppressionSystem input dictionary. If both ``core_mean_intensity`` and ``core_contrast`` are present in the input, then both will be populated, but in this case, the protoype *only* uses ``core_mean_intensity`` and ignores ``core_contrast``. However, other implementations may wish to utilize both (see, e.g., :py:class`~EXOSIMS.OpticalSystem.Nemati_2019`). The final starlightSuppressionSystem dictionary is guaranteed to include both ``core_contrast`` and ``core_mean_intensity`` keys, but their values will be None if they were not present in the input (except for the case where neither was specified, in which case ``core_contrast`` will contain the lambda function based on the top-level default and ``core_mean_itensity`` will be None.
+
+When using input files for ``occ_trans``, ``core_thruput``, ``core_contrast``, ``core_mean_intensity``, or ``core_area``, the file may be a FITS file or a CSV file.  For ``occ_trans``, ``core_thruput``, ``core_contrast``, and ``core_area``, FITS files must contain two rows (or columns) where the first represents angular separation and the second represents the parameter value. The FITS header should specify the angular separation unit in keyword ``UNITS`` which should either be ``unitless`` or ``LAMBDA/D`` for :math:`\lambda/D` units or contain a string that is parsable as an astropy length unit (https://docs.astropy.org/en/stable/units/format.html). If the keyword does not exist, units of ``input_angle_units`` will be assumed. For CSV files, the angular separation must be in a column with header ``csv_angsep_colname`` and the parameter value must be in a column with a header that is exactly the same as the keyword name (i.e., ``occ_trans``, etc.).  There is not required order for the columns in a CSV file, and other columns may also be present in the same file.  For CSV files, the angular separation unit is set by key ``input_angle_units``.
+
+For ``core_mean_intensity`` all data in FITS files **must** be in columns, with the first column containing the angular separation data. When more than two columns are present, FITS files must contain keywords of the form ``DIAM???`` where the ``???`` represent a zero-padded counter starting from 000 and up to the number of columns minus 1 (i.e., for a file with 32 columns, there are 31 intensity value columns, and we expect keywords of ``DIAM0000`` through ``DIAM030``. The values in these keywords are the stellar angular diameters in units given by the ``UNITS`` keyword.  In cases where there are only two columns present in the file, the data will be interpreted as for a stellar diameter of zero (unresolved).
+
+.. warning::
+
+    Stellar diameter-dependent ``core_mean_intensity`` inputs are not supported using CSV files.
+
+CSV file inputs for ``core_mean_intensity`` are treated exactly in the same way as all other CSV inputs:  the angular separation must be in a column with header equal to ``csv_angsep_colname``, there must be a column with header ``core_mean_intensity``, which will be interpreted as the zero-diameter (unresolved) core mean intensity, and angle units are set by ``input_angle_units``.
+
+For all 5 of these inputs (both for files and scalars), a lambda function will be generated, which takes as inputs the central wavelength of the observation and angular separation (both quantities with length and angle units, respectively). For ``core_mean_intensity``, there is a third input, representing the stellar diameter (again a quantity with angle units), which carries a default value of 0.  When the original input is a scalar value, the lambda function just returns this scalar, or zero in cases where the input angular separation is outside of the IWA/OWA range or the input wavelength is outside of the system's bandpass.  When the original input is table data, the lambda function returns the value of the interpolant defined over the table data at the relevant angular separation (and, in the case of ``core_mean_intensity``, the stellar diameter). For internal coronagraphs, the original table data represents angular separation for a particular wavelength (nominally the central wavelength of the system, stored in ``syst[lam]``, which we'll refer to as  :math:`\lambda_0`).  This is true even if the table data lists angular separation in physical angle units. If the observing wavelength does not match the system wavelength, then inputs must be scaled properly so as to evaluate the interpolant correctly.  The angular separation input to the interpolant is in physical angle units: to scale, we need to convert to :math:`\lambda/D` (for observing wavelength :math:`\lambda` - the input to the lambda function), which means dividing by :math:`\lambda/D`, and then convert back to a physical angle by scaling by :math:`\lambda_0/D`.  This is equivalent to defining our lambda function :math:`g` as: 
+
+.. math::
+
+    g(\lambda, s) = f\left(s\frac{\lambda_0}{\lambda}\right)
+
+where :math:`f(s)` is the original interpolant over the table data.  To validate this, consider the case where the observing wavelength is twice the system wavelength (i.e. :math:`\lambda = 2\lambda_0`).  This results in computing the interpolated value at *half* the input angular separation.  This makes sense, as the input angular separation for the redder wavelength corresponds to a smaller angular separation for the original, bluer system definition. The same scaling is also applied to the stellar diameter for ``core_mean_intensity``. For starshades, this scaling is *not* applied. 
+
+All of the outputs are unitless scalars, except for ``core_area``, which is returned as a quantity with units of square arcseconds.  For internal coronagraphs, the ``core_area`` output must also be scaled to account for differences in wavelength between the original system definition and the observing mode.  In this case, however, we are converting an output that is in angle units (squared) corresponding to the original wavelength :math:`\lambda_0` to a new wavelength :math:`\lambda` and so the scaling is the reciprocal of the one used for the input angular separation:
+
+.. math::
+
+    g(\lambda, s) = f\left(s\frac{\lambda_0}{\lambda}\right) \left(\frac{\lambda}{\lambda_0}\right)^2
+
+Once again, this scaling is *not* applied in the case of starshades. The ``core_platescale`` is always a scalar, and is stored internally as a scalar quantity (not callable) with physical angular units corresponding to the central wavelength of the original system definition.  It must be scaled to the observing wavelength whenever used (see :py:meth:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem.Cp_Cb_Csp_helper` for a reference implementation). 
+
+.. warning::
+
+    It is up to the user to ensure that angular values in physical angle units are properly computed at the central wavelength encoded in the ``lam`` parameter.  Note that this value will be filled in from a global default if no user input is supplied, which may result in discrepancies in the optical system definition and lead to unexpected behavior. Similarly, the  prototype implementation does *not* support using systems defined with one pupil diameter at a different pupil diameter.  If attempting to do this (discouraged) you must scale the angular units in your input files prior to use.
+
+
+Standardized Coronagraph Parameters
+"""""""""""""""""""""""""""""""""""""
+
+Chris Stark and John Krist have a standardized definition of coronagraph parameters (described in detail here: https://starkspace.com/yield_standards.pdf) consisting of 5 FITS files.  ``EXOSIMS`` provides a utility method (:py:meth:`~EXOSIMS.util.process_opticalsys_package.process_opticalsys_package`) for translating from these files to ``EXOSIMS`` standard input files.
+
+The sky transmission map (coronagraph mask throughput) is radially averaged and saved to a 2D FITS file of dimension :math:`n\times 2`:, where :math:`n` is the number of angular separations computed in the radial averaging (roughly one per pixel of radius about the image center in the original data). This file can then be used for input to the ``occ_trans`` system parameter.  An example is show in :numref:`fig:sky_trans_plot`
+
+.. _fig:sky_trans_plot:
+.. figure:: sky_trans_plot.png
+   :width: 100.0%
+   :alt: Coronagraph throughput map 
+    
+   Input sky transmission map (left) and output coronagraph throughput curve (right).
+
+The off-axis :term:`PSF` data is processed by finding the center of each PSF and then computing the total flux in an aperture around the center.  The center is either found by computing the center of mass of an upsampled (by default by a factor of 4) copy of the input data, with an overlaid Hamming window overlaid at the location of the astrophysical PSF offset, or by fitting a 2D Gaussian to the upsampled (but non-windowed) image.  In the former case, the throughput is computed in a fixed aperture (with default radius of :math:`\sqrt{2}/2\, \lambda/D`).  In the latter case, the throughput is computed within an area defined by the average of the :term:`FWHM` values of the two axes of the fit Gaussian. It is also possible to specify a minimum photometric aperture in the case of Gaussian fits (via keyword ``use_phot_aperture_as_min``). 
+
+.. _fig:offaxpsf_thruput_anim:
+.. figure:: offaxpsf_thruput_anim.gif
+   :width: 100.0%
+   :alt: Off-axis PSF
+    
+   Input off-axis PSF data (left) and output throughput curves (right) for multiple different processing options. The black + symbol indicates the astrophysical offset of the PSF in the input data.
+
+:numref:`fig:offaxpsf_thruput_anim` shows an animation of the off-axis PSF centroiding and aperture photometry procedure and resulting throughput curves for a sample data set.  The two methods implemented in :py:meth:`~EXOSIMS.util.process_opticalsys_package.process_opticalsys_package` (windowed center of mass and Gaussian fitting of upsampled images) are compared with quadratic centroiding and aperture photometry via the ``photutils`` package (https://photutils.readthedocs.io/).  In all cases except for the Gaussian fit, a fixed aperture size of :math:`\sqrt{2}/2\,\lambda/D` is used.  The Gaussian fit, in this case, typically generates a smaller FWHM measurement, resulting in a lower computed throughput. We can see that all fitting procedures fail, to varying degrees, when the PSF is partially or fully obscured by the coronagraphic masks or when it moves outside the field of view of the system. However, the 'true' throughput values in all such cases are near zero (and contrast is similarly negligible), and so these fitting errors will have no impact on simulations.   The resulting throughput curve is saved to a 2D FITS file of dimension :math:`m\times 2`, where :math:`m` is the number of discrete astrophysical offsets in the original data set (i.e., the dimension of the data in the ``offax_psf_offset_list`` input. This file is then used as the input to the ``core_thruput`` parameter. In addition the area of the photometric aperture used in these computation is written out to a separate file (of the same dimensionality) to be used for the ``core_area`` input.  In cases where a fixed aperture is used, all values of the core area file are identical, and the file can be replaced with a scalar input. However, the values will differ for Gaussian fits. 
+
+Finally, the stellar intensity data is processed by computing a radial average at each stellar diameter used.  The results are written to a FITS file of dimension :math:`k+1\times n`, where :math:`k` is the number of stellar diameters in the original data (i.e., the dimension of the ``stellar_intens_diam_list`` input) and math:`n` is again the number of angular separations computed in the radial averaging.  In cases where the image size of the stellar intensity maps is the same as that of the sky transmission map (and with the same center pixel), then these two :math:`n` values should be identical.  The first row of the data is the angular separations of the radial average.  The stellar diameters themselves are written to the FITS header in keywords of the form ``DIAM???`` where the ``???`` represents a zero-padded number.  So, if there are 31 discrete stellar diameters in the input data set, then the resulting FITS header will have keywords ``DIAM000`` through ``DIAM030``.
+
+.. _fig:stellar_intens_anim:
+.. figure:: stellar_intens_anim.gif
+   :width: 100.0%
+   :alt: Off-axis PSF
+    
+   Input stellar intensity data (left) and output intensity curves (right) for various stellar diameters. 
+
+:numref:`fig:stellar_intens_anim` shows an animation of the stellar intensity evolution as a function of stellar diameter.
+
+
 
 Observing Mode
-"""""""""""""""""
+^^^^^^^^^^^^^^^^^^^^^^
 
 An observing mode is the combination of a science instrument with a starlight suppression system along with rules for determining integration times. The observing mode can also specify additional parameters overwriting the values in the two sub-systems. One observing mode in the optical system must be tagged as the default detection mode (by setting boolean keyword ``detectionMode`` to True).  This is the mode used for all blind searches or initial target observations.
 
@@ -151,6 +293,28 @@ Common observing mode attributes include:
 * BW (float):
     Bandwidth fraction
 
+If both ``deltaLam`` and ``BW`` are set, ``deltaLam`` will be used preferentially, and ``BW`` will be recalculated from ``deltaLam`` and ``lam``.  If any bandpass values are not set in the ``observingMode`` inputs, they will be inherited from the mode's starlight suppression system. Similarly, the :term:`IWA` and :term:`OWA` will be copied from the starlight suppression system, unless set in the mode's inputs.  Upon instantiation, each ``ObservingMode`` will define its bandpass (stored in attribute ``bandpass``) as a :py:class:`~synphot.spectrum.SpectralElement` object.  The model used will be either a :py:class:`~EXOSIMS.util.photometricModels.Box1D` (default) or :py:class:`~synphot.models.Gaussian1D`, toggled by attribute ``bandpass_model``.  For a :py:class:`~EXOSIMS.util.photometricModels.Box1D` model, a step size can also be specificed via attribute ``bandpass_step`` (default is 1 :math:`\mathring{A}`).  
+
+Initialization
+^^^^^^^^^^^^^^^^^^^^^^
+
+In order to build an optical system, the prototype ``__init__`` first assigns reserved inputs to attributes, and then collects all other inputs into a single attribute (:py:attr:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem.default_vals`), which are also copied to the :ref:`sec:outspec`.  It then calls three methods in sequence, as shown in :numref:`fig:OS_init`.
+
+.. _fig:OS_init:
+.. mermaid:: opticalsystem_init.mmd
+   :caption: OpticalSystem Prototype ``__init__``.
+
+These are: :py:meth:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem.populate_scienceInstruments`, :py:meth:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem.populate_starlightSuppressionSystems` and :py:meth:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem.populate_observingModes`, respectively.  Each of these methods is responsible for populating all of the required elements of each aspect of the optical system, and copy the input values (or substituted defaults) into the :ref:`sec:outspec`. Each method also calls (immediately before returning), a helper method of the same name with ``_extra`` appended (e.g. :py:meth:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem.populate_scienceInstruments_extra`).  These are there to allow overloaded implementation to expand the definitions of each optical system element, and are left blank in the prototype. Each of the three top-level ``populate_*`` methods also creates an attribute called ``allowed_*_kws`` (e.g. ``allowed_scienceInstrument_kws``, etc.). These lists contain all keywords that are allowed for that particular dictionary type for a given optical system implementation.  If additional keywords are added via the ``*_extra`` methods, then the keyword names must be added to the relevant list as well (see :py:meth:`~EXOSIMS.OpticalSystem.Nemati.Nemati.populate_scienceInstruments_extra` for an implementation example). These lists are used to check for :ref:`sec:inputspec` consistency using method :py:meth:`~EXOSIMS.util.keyword_fun.check_opticalsystem_kws`.
+
+.. important::
+
+    It is up to each implementation to ensure proper handling of inputs and defaults values, to copy all new optical system elements to the ``_outspec``, and to augment the allowed_*_kws lists. 
+
+.. warning::
+
+    When defining an optical system that inherits another implementation (rather than directly inheriting the prototype), be sure to call all levels of the ``_extra`` methods.  That is, if the implementation you inherit has its own ``populate_scienceInstruments_extra`` and you wish to add to it, your method's first line should be something like ``super().populate_scienceInstruments_extra()``.
+
+
 
 Optical System Methods
 -------------------------
@@ -158,23 +322,87 @@ Optical System Methods
 Various different optical system models will have a variety of methods, but all optical systems are expected to provide the following:
 
 Cp_Cb_Csp
-"""""""""""
+^^^^^^^^^^^^^^^^^^^^^^
+
+This method computes the count rates (electrons or counts per unit time) for the planet (:math:`C_p`), the background (:math:`C_b`), and the residual speckle (:math:`C_{sp}`).  The last of these typically determines the systematic noise floor of the system.  In a simple optical system model, the foreground and background rates are likely entirely independent of one another (i.e.,  :math:`C_b` and :math:`C_{sp}` have no dependence on :math:`C_p`), but this is not actually a requirement.  More complicated descriptions, including those of electron-multiplying CCDs run in photon counting mode, will have clock-induced-charge coupling the foreground and background counts. Given the fundamental definitions in :ref:`photometry`, the basic elements are evaluated as follows:
+
+* The count rate due to the star is: 
+
+  .. math::
+    
+    C_\textrm{star} = F_S A \tau
+  
+  where :math:`F_S` is the star flux density in the observing band and :math:`\tau` accounts for all non-coronagraphic, throughput losses. The total attenuation due to any fore-optics and any relay optics in the starlight suppression system and science instrument.  This includes losses due to all reflective and transmissive elements *after* the primary, *excluding* the throughput of any coronagraphic pupil and focal plane masks. The detector :term:`QE` is also factored into this expression, either by convolution with the bandpass used to integrate :math:`F_S`, or as a scalar factor folded into :math:`\tau` (in which case the QE is evaluated at the bandpass central wavelength. Note that this expression represents the stellar count rate in the absence of the coronagraph (but including throughput losses due to all other optics up through the detector).
+
+* The stellar residual count is:
+
+  .. math::
+    C_\textrm{speckle} = C_\textrm{star} I_\textrm{core}
+  
+  where :math:`I_\textrm{core}` is the coronagraph core intensity scaled by the size of the photometric aperture (this maps to the :math:`I` definition from [StarkKrist2019]_). 
+
+* Given a star-planet difference in magnitude :math:`\Delta\mathrm{mag}` in the observing band, the planet count rate is given by:
+
+  .. math::
+    C_\textrm{planet} = C_\textrm{star} 10^{-0.4 \Delta\textrm{mag}} \tau_\textrm{core}(\lambda_0, \alpha)
+    
+  where :math:`\tau_\textrm{core}` is the coronagraphic core throughput, parametrized by the bandpass central wavelength (:math:`\lambda_0`) and the angular separation of the planet (:math:`\alpha`). This maps to the term $\Upsilon(x,y)$ in [StarkKrist2019]_. In the absence of a specific planet spectrum, :math:`\Delta\textrm{mag}` is assumed achromatic.
+
+* Given the specific intensity of the local zodiacal light (:math:`I_\textrm{zodi}`), the zodiacal light count rate is:
+  
+  .. math::
+    
+    C_\textrm{zodi} = I_\textrm{zodi}\Omega \Delta\lambda \tau A \tau_\textrm{occ}
+
+  where :math:`\Omega` is the the solid angle of the photometric aperture being used and :math:`\tau_\textrm{occ}` is the occulter transmission. This is typically parametrized in the same way as :math:`\tau_\textrm{core}` and maps to the :math:`T_{sky}(x,y)` value as defined in [StarkKrist2019]_. For further disucssion on :math:`I_\textrm{zodi}`, see: :ref:`zodiandexozodi` and :ref:`zodiacallight`.
+
+*  Given the specific intensity of the exozodiacal light (:math:`I_\textrm{exozodi}`), the exozodiacal light count rate is:
+  
+  .. math::
+    
+    C_\textrm{exozodi} = I_\textrm{exozodi}\Omega \Delta\lambda \tau A \tau_\textrm{core}
+
+  Note that use of :math:`\tau_\textrm{core}` vs. :math:`\tau_\textrm{sky}` is a design decision for the prototype ``OpticalSystem`` and may be overridden by other ``OpticalSystem`` implementations. 
+
+* The dark current count rate is:
+
+  .. math::
+
+    C_\textrm{dark} = n_\textrm{pix} \textrm{DC}
+
+  where :math:`n_\textrm{pix}` is the number of pixels in the photometric aperture being used, while DC is the dark current rate in units of electrons/pixel/time.
+
+* The read noise count rate is:
+
+  .. math::
+
+    C_\textrm{read} = n_\textrm{pix} \frac{RN}{t_\textrm{read}}
+
+  where :math:`t_\textrm{read}` is the time of each readout and RN is the read noise in units of electrons/pixel/read.
+
+* The speckle residual is modeled as the variance of the residual starlight that cannot be removed via post-processing.  This value (which is added in quadrature to the background to determine integration time) is defined as:
+
+  .. math::
+  
+    C_\textrm{sp} = C_\textrm{speckle} \textrm{pp}(\alpha) \textrm{SF}
+
+  where :math:`\textrm{pp}` is the post-processing factor (defined as the reciprocal of the post-process gain, such that  a reduction in speckle noise of 10x is equivalent to a pp of 0.1), parametrized by the planet's angular separation, and SF is a stability factor, used to model the overall PSF stability. Note that setting the stability factor to zero is equivalent to modeling a system with no inherent noise floor.  See: :ref:`PostProcessing`. 
 
-This method computes the count rates (electrons per unit time) for the planet (:math:`C_p`), the background (:math:`C_b`), and the residual speckle (:math:`C_{sp}`).  The last of these typically determines the systematic noise floor of the system.  In a simple optical system model, the foreground and background rates are likely entirely independent of one another (i.e.,  :math:`C_b` and :math:`C_{sp}` have no dependence on :math:`C_p`), but this is not actually a requirement.  More complicated descriptions, including those of electron-multiplying CCDs run in photon counting mode, will have clock-induced-charge coupling the foreground and background counts. See:  :py:meth:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem.Cp_Cb_Csp`.
+Other detector-specific noise sources depend on the detector model and may include clock-induced charge, photon counting efficiency factors and degradation factors due to radiation dose and other effects. See: :py:meth:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem.Cp_Cb_Csp`.
 
 calc_intTime
-"""""""""""""""""
+^^^^^^^^^^^^^^^^^^^^^^
 
 Calculate the integration time required to reach the selected observing mode's target SNR on one or more targets for a planet of given :math:`\Delta\mathrm{mag}` at a given angular separation. If the SNR is unreachable by the selected observing mode, return NaN. See::py:meth:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem.calc_intTime`.
 
 calc_dMag_per_intTime
-"""""""""""""""""""""""
+^^^^^^^^^^^^^^^^^^^^^^
 
 Calculate the maximum :math:`\Delta\mathrm{mag}` planet observable at the observing mode's target SNR with the given integration time, at the given angular separation.  This should be a strict inverse of ``calc_intTime``.  See: :py:meth:`~EXOSIMS.Prototypes.OpticalSystem.OpticalSystem.calc_dMag_per_intTime`.
 
 
 ddMag_dt
-"""""""""""""
+^^^^^^^^^^^^^^^^^^^^^^
 
 Calculate:
 
diff --git a/documentation/opticalsystem_init.mmd b/documentation/opticalsystem_init.mmd
new file mode 100644
index 000000000..127f86b93
--- /dev/null
+++ b/documentation/opticalsystem_init.mmd
@@ -0,0 +1,15 @@
+flowchart TB
+    subgraph scienceInstruments[ ]
+    direction LR
+    C[[<pre>self.populate_scienceInstruments</pre>]] --> D[[<pre>self.populate_scienceInstruments_extra</pre>]]
+    end
+    subgraph starlightSuppressionSystems[ ]
+    E[[<pre>self.populate_starlighSuppressionSystems</pre>]] -->F[[<pre>self.populate_starlighSuppressionSystems_extra</pre>]]
+    end
+    subgraph observingModes[ ]
+    G[[<pre>self.populate_observingModes</pre>]] -->H[[<pre>self.populate_observingModes_extra</pre>]]
+    end
+
+    A([Assign reserved keywords to attributes and copy to: <pre>self._outspec</pre>])-->B([Collect all other inputs into: <pre>self.default_vals</pre>])-->scienceInstruments-->starlightSuppressionSystems-->observingModes
+
+
diff --git a/documentation/pickles_bpgs_G0V.png b/documentation/pickles_bpgs_G0V.png
new file mode 100644
index 000000000..7fcb1992b
Binary files /dev/null and b/documentation/pickles_bpgs_G0V.png differ
diff --git a/documentation/pickles_bpgs_G0V_diffs.png b/documentation/pickles_bpgs_G0V_diffs.png
new file mode 100644
index 000000000..7f6a35d53
Binary files /dev/null and b/documentation/pickles_bpgs_G0V_diffs.png differ
diff --git a/documentation/puppeteer-config.json b/documentation/puppeteer-config.json
new file mode 100644
index 000000000..3201af7b7
--- /dev/null
+++ b/documentation/puppeteer-config.json
@@ -0,0 +1,3 @@
+{
+  "args": ["--no-sandbox"]
+}
diff --git a/documentation/refs.rst b/documentation/refs.rst
index 7a7bded20..f5e94e331 100644
--- a/documentation/refs.rst
+++ b/documentation/refs.rst
@@ -2,34 +2,56 @@
 
 References
 =============
-.. [Agol2007] Agol, E. (2007) Rounding up the wanderers: optimizing coronagraphic searches for extrasolar planets, Monthly Notices of the Royal Astronomical Society 374(4)
+.. [Agol2007] Agol, E. (2007) "Rounding up the wanderers: optimizing coronagraphic searches for extrasolar planets", Monthly Notices of the Royal Astronomical Society, 374(1271), http://dx.doi.org/10.1111/j.1365-2966.2006.11232.x
 
-.. [Brown2004] Brown, R. A. (2004) New Information from Radial Velocity Data Sets, ApJ 610
+.. [Apellaniz2006] Maíz Apellániz, J. (2006) "A Recalibration of Optical Photometry: Tycho-2, Strömgren, and Johnson Systems", The Astronomical Journal, 131, 1184, http://dx.doi.org/10.1086/499158
 
-.. [Brown2005] Brown, R. A. (2005) Single-visit photometric and obscurational completeness, ApJ 624
+.. [Ballesteros2012] Ballesteros, F. J. (2012) "New insights into black bodies", EPL (Europhysics Letters), 97(34008), http://dx.doi.org/10.1209/0295-5075/97/34008
 
-.. [Chen2016] Chen, J. and Kipping, D. M. (2016) Probabilistic Forecasting of the Masses and Radii of Other Worlds, ApJ 834(1)
+.. [Bessell1983] Bessell, M. S. (1983) "VRI photometry : an addendum.", Publications of the Astronomical Society of the Pacific, 95(480) http://dx.doi.org/10.1086/131196
 
-.. [Henry1993] Henry, T. J. and McCarthy Jr., D. W. (1993) The Mass-Luminosity Relation for stars of mass 1.0 to 0.08 :math:`M_\odot`, AJ 106(2) 
+.. [Bessell1988] Bessell, M. S., & Brett, J. M. (1988) "JHKLM Photometry: Standard Systems, Passbands, and Intrinsic Colors", Publications of the Astronomical Society of the Pacific, 100(1134), http://dx.doi.org/10.1086/132281
 
-.. [Garrett2016] Garett, D. and Savransky, D. (2016) Analytical Formulation of the Single-visit Completeness Joint Probability Density Function, ApJ 828(1); https://iopscience.iop.org/article/10.3847/0004-637X/828/1/20
+.. [Boyajian2013] Boyajian, T. S., von Braun, K., van Belle, G., Farrington, C., Schaefer, G., Jones, J., White, R., McAlister, H. A., ten Brummelaar, T. A., Ridgway, S., Gies, D., Sturmann, L., Sturmann, J., Turner, N. H., Goldfinger, P. J., & Vargas, N.  (2013) "Stellar Diameters and Temperatures. III. Main-sequence A, F, G, and K Stars: Additional High-precision Measurements and Empirical Relations", The Astrophysical Journal, 771(40) http://dx.doi.org/10.1088/0004-637X/771/1/40
 
-.. [Garrett2017] Garett, D., Savransky, D., and Macintosh, B. (2017) A Simple Depth-of-Search Metric for Exoplanet Imaging Surveys, AJ 154(2); https://iopscience.iop.org/article/10.3847/1538-3881/aa78f6
+.. [Boyajian2014] Boyajian, T. S., van Belle, G., & von Braun, K. (2014) "Stellar Diameters and Temperatures. IV. Predicting Stellar Angular Diameters", The Astronomical Journal, 147(47), http://dx.doi.org/10.1088/0004-6256/147/3/47
 
-.. [Kasdin2006] Kasdin, N. J. and Braems, I. (2006) Linear and Bayesian Planet Detection Algorithms for the Terrestrial Planet Finder, ApJ 646
+.. [Brown2004] Brown, R. A. (2004) "New Information from Radial Velocity Data Sets", The Astrophysical Journal, 610(1079), http://dx.doi.org/10.1086/421896
 
-.. [Keithly2021] Keithly, D. and Savransky, D. (2021) The Solar System as an Exosystem: Planet Confusion, ApJL 919(1)
+.. [Brown2005] Brown, R. A. (2005) "Single-Visit Photometric and Obscurational Completeness", The Astrophysical Journal, 624(1010), http://dx.doi.org/10.1086/429124
 
-.. [Keithly2021b] Keithly, D. and Savransky, D. (2021) Integration time adjusted completeness, JATIS 7(3)
+.. [Chen2016] Chen, J., & Kipping, D. (2017) "Probabilistic Forecasting of the Masses and Radii of Other Worlds", The Astrophysical Journal, 834(17), http://dx.doi.org/10.3847/1538-4357/834/1/17
 
-.. [Nemati2014] Nemati, Bijan (2014) Detector selection for the WFIRST-AFTA coronagraph integral field spectrograph, Proc. SPIE, 91430
+.. [HanburyBrown1974] Hanbury Brown, R., Davis, J., Lake, R. J. W., & Thompson, R. J. (1974) "The effects of limb darkening on measurements of angular size with an intensity interferometer", Monthly Notices of the Royal Astronomical Society, 167(475), http://dx.doi.org/10.1093/mnras/167.3.475
 
-.. [Stark2014] Stark, C., Roberge, A., Mandell, A., and Robinson, T. D. (2014) Maximizing the ExoEarth Candidate Yield from a Future Direct Imaging Mission, ApJ 795(2)
+.. [Henry1993] Henry, T. J., & McCarthy, D. W. (1993) "The Mass-Luminosity Relation for Stars of Mass 1.0 to 0.08M(solar)", The Astronomical Journal, 106(773), http://dx.doi.org/10.1086/116685 
 
-.. [Savransky2010] Savransky, D., Kasdin, N. J., and Cady, E. (2010) Analyzing the designs of planet finding missions, PASP 122(890); https://iopscience.iop.org/article/10.1086/652181
+.. [Garrett2016] Garrett, D., & Savransky, D. (2016) "Analytical Formulation of the Single-visit Completeness Joint Probability Density Function", The Astrophysical Journal, 828(20), http://dx.doi.org/10.3847/0004-637X/828/1/20
 
-.. [Savransky2015] Savransky, D., and Garrett, D., (2015) WFIRST-AFTA coronagraph science yield modeling with EXOSIMS, JATIS 2(1); http://dx.doi.org/10.1117/1.JATIS.2.1.011006
+.. [Kasdin2006] Kasdin, N. J., & Braems, I. (2006) "Linear and Bayesian Planet Detection Algorithms for the Terrestrial Planet Finder", The Astrophysical Journal, 646(1260), http://dx.doi.org/10.1086/505017
 
-.. [Sobolev1975] Sobolev, V. V. (1975) Light Scattering in Planetary Atmospheres, Pergamon Press: New York
+.. [Keithly2020] Keithly, D. R., Savransky, D., Garrett, D., Delacroix, C., & Soto, G. (2020) "Optimal scheduling of exoplanet direct imaging single-visit observations of a blind search survey", Journal of Astronomical Telescopes, Instruments, and Systems, 6(027001), http://dx.doi.org/10.1117/1.JATIS.6.2.027001
+
+.. [Keithly2021] Keithly, D. R., & Savransky, D. (2021) "The Solar System as an Exosystem: Planet Confusion", The Astrophysical Journal, 919(L11), http://dx.doi.org/10.3847/2041-8213/ac20cf
+
+.. [Keithly2021b] Keithly, D., Savransky, D., & Spohn, C. (2021) "Integration time adjusted completeness", Journal of Astronomical Telescopes, Instruments, and Systems, 7(037002), http://dx.doi.org/10.1117/1.JATIS.7.3.037002
+
+.. [Leinert1998] Leinert, C., Bowyer, S., Haikala, L. K., Hanner, M. S., Hauser, M. G., Levasseur-Regourd, A.-C., Mann, I., Mattila, K., Reach, W. T., Schlosser, W., Staude, H. J., Toller, G. N., Weiland, J. L., Weinberg, J. L., & Witt, A. N.  (1998) "The 1997 reference of diffuse night sky brightness", Astronomy and Astrophysics Supplement Series, 127(1) http://dx.doi.org/10.1051/aas:1998105
+
+.. [Nemati2014] Nemati, B. (2014) "Detector selection for the WFIRST-AFTA coronagraph integral field spectrograph", Space Telescopes and Instrumentation 2014: Optical, Infrared, and Millimeter Wave, 9143(91430Q), http://dx.doi.org/10.1117/12.2060321
+
+.. [Rieke2008] Rieke, G. H., Blaylock, M., Decin, L., Engelbracht, C., Ogle, P., Avrett, E., Carpenter, J., Cutri, R. M., Armus, L., Gordon, K., Gray, R. O., Hinz, J., Su, K., and Willmer, C. N. A. (2008) Absolute Physical Calibration in the Infrared, AJ 135(2245), http://dx.doi.org/10.1088/0004-6256/135/6/2245
 
+.. [Stark2014] Stark, C. C., Roberge, A., Mandell, A., & Robinson, T. D. (2014) "Maximizing the ExoEarth Candidate Yield from a Future Direct Imaging Mission", The Astrophysical Journal, 795(122), http://dx.doi.org/10.1088/0004-637X/795/2/122
+
+.. [Stark2015] Stark, C. C., Roberge, A., Mandell, A., Clampin, M., Domagal-Goldman, S. D., McElwain, M. W., & Stapelfeldt, K. R.  (2015) "Lower Limits on Aperture Size for an ExoEarth Detecting Coronagraphic Mission", The Astrophysical Journal, 808(149) http://dx.doi.org/10.1088/0004-637X/808/2/149
+
+.. [StarkKrist2019] Stack, C. C. and Krist, J. (2019) "Standardized Coronagraph Parameters for Input into Yield Calculation", https://starkspace.com/yield_standards.pdf
+
+.. [Savransky2010] Savransky, D., Kasdin, N. J., & Cady, E. (2010) "Analyzing the Designs of Planet-Finding Missions", Publications of the Astronomical Society of the Pacific, 122(401), http://dx.doi.org/10.1086/652181
+
+.. [Savransky2015] Savransky, D., and Garrett, D., (2015) "WFIRST-AFTA coronagraph science yield modeling with EXOSIMS", JATIS 2(1); http://dx.doi.org/10.1117/1.JATIS.2.1.011006
+
+.. [Sobolev1975] Sobolev, V. V. (1975) Light Scattering in Planetary Atmospheres, Pergamon Press: New York
 
+.. [Traub2016] Traub, W. A., Breckinridge, J., Greene, T. P., Guyon, O., Jeremy Kasdin, N., & Macintosh, B. (2016) "Science yield estimate with the Wide-Field Infrared Survey Telescope coronagraph", Journal of Astronomical Telescopes, Instruments, and Systems, 2(011020), http://dx.doi.org/10.1117/1.JATIS.2.1.011020
diff --git a/documentation/sky_trans_plot.png b/documentation/sky_trans_plot.png
new file mode 100644
index 000000000..ce79de16f
Binary files /dev/null and b/documentation/sky_trans_plot.png differ
diff --git a/documentation/stellar_intens_anim.gif b/documentation/stellar_intens_anim.gif
new file mode 100644
index 000000000..526ddd854
Binary files /dev/null and b/documentation/stellar_intens_anim.gif differ
diff --git a/documentation/synphot_standard_bands.png b/documentation/synphot_standard_bands.png
new file mode 100644
index 000000000..50e49d970
Binary files /dev/null and b/documentation/synphot_standard_bands.png differ
diff --git a/documentation/targetlist.rst b/documentation/targetlist.rst
index 62d5f4a07..8cad801fc 100644
--- a/documentation/targetlist.rst
+++ b/documentation/targetlist.rst
@@ -7,11 +7,9 @@ The target list modules take catalog data from a :ref:`StarCatalog` object and,
 
 
 .. _fig:TL_init_flowchart:
-.. figure:: TL_init_flowchart.png
-   :width: 100.0%
-   :alt: TargetList initialization flow chart
+.. mermaid:: targetlist_init.mmd
+   :caption: TargetList Prototype ``__init__``.
 
-   Initialization of a prototype TargetList.
 
 After parsing keyword inputs and instantiating objects of :ref:`StarCatalog`, :ref:`OpticalSystem`, :ref:`PostProcessing`,  :ref:`ZodiacalLight`, and :ref:`Completeness`, the prototype :py:class:`~EXOSIMS.Prototypes.TargetList` initialization calls :py:meth:`~EXOSIMS.TargetList.TargetList.populate_target_list`, which makes :ref:`StarCatalog` attribute property arrays attributes of the ``TargetList`` object, fills in missing photometric data (if the ``fillPhotometry`` keyword input is set to True), and assigns each target system additional, computed attributes: 
 
diff --git a/documentation/targetlist_init.mmd b/documentation/targetlist_init.mmd
new file mode 100644
index 000000000..13fb44169
--- /dev/null
+++ b/documentation/targetlist_init.mmd
@@ -0,0 +1,119 @@
+flowchart TB
+    subgraph TLinit["<pre>TargetList.__init__</pre>"]
+    direction TB
+
+    subgraph submods[Instantiate submodules]
+        direction LR
+        SM1[StarCatalog]---
+        SM2[OpticalSystem]---
+        SM3[ZodiacalLight]---
+        SM4[PostProcessing]---
+        SM5[Completeness]
+        linkStyle 0 stroke-width:0px
+        linkStyle 1 stroke-width:0px
+        linkStyle 2 stroke-width:0px
+        linkStyle 3 stroke-width:0px
+    end  
+    B0([" "])---B1([Assign reserved keywords to attributes and copy to: <pre>self._outspec</pre>])-->submods
+    style B0 fill:none,stroke-width:0px
+    linkStyle 4 stroke-width:0px
+
+    subgraph detmode[Identify detection mode]
+        B3{<pre>int_WA</pre> is None}
+        B3 -- True --> B4{"<pre>detmode[OWA]</pre>is infinite"}
+        B4 -- True --> B5("<pre>int_WA = 2*detmode[IWA]</pre>")
+        B4 -- False --> B6("<pre>int_WA = 1/2 * (detmode[IWA] + 
+        detmode[OWA])</pre>")
+        B5 & B6-->B7([Assign <pre>int_WA</pre> and <pre>int_dMag</pre> as array attributes])
+        B3 --> False -->B7
+    end
+    submods-->detmode
+    detmode--> B8[["<pre>self.set_catalog_attributes()</pre> Populates catalog_atts and required_catalog_atts"]]
+    B8-->B9[["<pre>self.populate_target_list()</pre>Copies catalog_atts from StarCatalog"]]
+    B9-->B10(["<pre>if self.popStars is not None()</pre>Remove all targets in popStars"])
+    
+    subgraph fillphot["<pre>self.fillPhotometryVals()</pre>"]
+        direction TB
+
+        subgraph foreachtarg["For Each Target"]
+            direction LR
+            B12{"Spectral type 
+            can be determined"}
+            B12 -- False --> B13{"<pre>self.fillPhotometry</pre> is True 
+            and type can be determined
+            from other data"}
+            B13 -- True --> B14{"Target is 
+            White dwarf 
+            or subdwarf"}
+            B13 -- False --> B15([Drop Target])
+            B12 -- True --> B14
+            B14 -- True --> B15
+            B14 -- False --> B16([Keep Target])
+        end
+        B11([" "])---foreachtarg
+        style B11 fill:none,stroke-width:0px
+        linkStyle 23 stroke-width:0px
+
+        subgraph fillphot2["<pre>if self.fillPhotometry is True</pre>"]
+            direction LR
+            B17(["Fill as many missing values as possible for <pre>MV, L, BC, Vmag, Bmag, BV</pre>"])-->
+            B18(["<pre> if self.fillMissingBandMags is True</pre> Fill as many other band mags as possible"])
+        end
+        foreachtarg-->fillphot2
+    end
+    B10 --> fillphot
+    fillphot-->B19[["<pre>self.nan_filter()</pre> Remove targets with NaN in any required_catalog_atts"]]
+
+    subgraph othercalcs["Fill Other Target Properties as Needed"]
+        direction LR
+        B20[["<pre>self.stellar_Teff()</pre>"]]---
+        B21[["<pre>self.stellar_mass()</pre>"]]---
+        B22[["<pre>self.stellar_diameter()</pre>"]]---
+        B23[["<pre>self.gen_inclinations()</pre>Generate system plane inclination"]]
+        linkStyle 28 stroke-width:0px
+        linkStyle 29 stroke-width:0px
+        linkStyle 30 stroke-width:0px
+    end  
+    B19-->othercalcs
+
+    subgraph calcsatandcutoff["<pre>self.calc_saturation_and_intCutoff_vals()</pre>Calculate saturation and intCutoff delta mags and completeness values"]
+        direction LR 
+        B240([" "])
+        style B240 fill:none,stroke-width:0px
+        subgraph calcsatsub1[" "]
+        direction TB
+        B24a[["<pre>self.OpticalSystem.calc_saturation_dMag()</pre>"]]-->
+        B24b[["<pre>self.Completeness.comp_calc()"]]
+        end
+        style calcsatsub1 fill:none,stroke-width:0px
+
+        subgraph calcsatsub2[" "]
+        direction TB
+        B24c[["<pre>self.OpticalSystem.calc_dMag_per_intTime(intCutoff)</pre>"]]-->
+        B24d[["<pre>self.Completeness.comp_calc()"]]
+        end
+        style calcsatsub2 fill:none,stroke-width:0px
+        calcsatsub1  --- calcsatsub2
+        
+    end
+    othercalcs-->calcsatandcutoff
+    linkStyle 34 stroke-width:0px
+    
+    subgraph compcalcs["Completeness Calculations"]
+        direction LR
+        B25[["<pre>self.Completeness.target_completeness()</pre>Calculate completeness for each target"]]---
+        B26[["<pre> self.Completeness.gen_update()</pre>Generate dynamic completeness data"]]
+        linkStyle 36 stroke-width:0px
+    end  
+    calcsatandcutoff-->compcalcs
+    compcalcs--> B27[["<pre> self.filter_target_list()</pre>Apply any other requested filtering"]]
+
+
+
+    end
+
+
+
+
+
+
diff --git a/documentation/templates_w_blackbody.png b/documentation/templates_w_blackbody.png
new file mode 100644
index 000000000..4590f0390
Binary files /dev/null and b/documentation/templates_w_blackbody.png differ
diff --git a/documentation/traub_v_synphot_Bband.png b/documentation/traub_v_synphot_Bband.png
new file mode 100644
index 000000000..f61d69cb2
Binary files /dev/null and b/documentation/traub_v_synphot_Bband.png differ
diff --git a/documentation/traub_v_synphot_Kband.png b/documentation/traub_v_synphot_Kband.png
new file mode 100644
index 000000000..de4886a94
Binary files /dev/null and b/documentation/traub_v_synphot_Kband.png differ
diff --git a/documentation/traub_v_synphot_Vband.png b/documentation/traub_v_synphot_Vband.png
new file mode 100644
index 000000000..436c94dec
Binary files /dev/null and b/documentation/traub_v_synphot_Vband.png differ
diff --git a/documentation/traub_v_synphot_deltaLambda_errs.png b/documentation/traub_v_synphot_deltaLambda_errs.png
new file mode 100644
index 000000000..6dda263d4
Binary files /dev/null and b/documentation/traub_v_synphot_deltaLambda_errs.png differ
diff --git a/documentation/traub_v_synphot_deltaLambda_errs_mainseq.png b/documentation/traub_v_synphot_deltaLambda_errs_mainseq.png
new file mode 100644
index 000000000..464a1a58e
Binary files /dev/null and b/documentation/traub_v_synphot_deltaLambda_errs_mainseq.png differ
diff --git a/documentation/zodi_color_leinert19.png b/documentation/zodi_color_leinert19.png
new file mode 100644
index 000000000..de84e3030
Binary files /dev/null and b/documentation/zodi_color_leinert19.png differ
diff --git a/documentation/zodi_intensity_leinert17.png b/documentation/zodi_intensity_leinert17.png
new file mode 100644
index 000000000..599c112cb
Binary files /dev/null and b/documentation/zodi_intensity_leinert17.png differ
diff --git a/documentation/zodi_latitudinal_variation.png b/documentation/zodi_latitudinal_variation.png
new file mode 100644
index 000000000..460db0e1b
Binary files /dev/null and b/documentation/zodi_latitudinal_variation.png differ
diff --git a/setup.py b/setup.py
index 127c3c68a..ab23130ea 100644
--- a/setup.py
+++ b/setup.py
@@ -1,6 +1,7 @@
 import setuptools
 import os.path
 import re
+import pkg_resources
 
 use_cython = True
 try:
@@ -35,6 +36,9 @@
 else:
     raise RuntimeError("Unable to find version string.")
 
+with open("requirements.txt", "r") as f:
+    reqs = [str(r) for r in pkg_resources.parse_requirements(f)]
+
 setuptools.setup(
     name="EXOSIMS",
     version=version_string,
@@ -46,23 +50,12 @@
     url="https://github.com/dsavransky/EXOSIMS",
     packages=setuptools.find_packages(exclude=["tests*", "tools*"]),
     include_package_data=True,
-    install_requires=[
-        "numpy",
-        "scipy",
-        "astropy",
-        "jplephem",
-        "h5py",
-        "ortools",
-        "exo-det-box",
-        "tqdm",
-        "pandas",
-        "astroquery",
-        "MeanStars",
-    ],
+    install_requires=reqs,
     classifiers=[
         "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
+        "Programming Language :: Python :: 3.10",
         "License :: OSI Approved :: BSD License",
         "Operating System :: OS Independent",
     ],
diff --git a/tools/gen_spectral_library_index.py b/tools/gen_spectral_library_index.py
new file mode 100644
index 000000000..fea842c6f
--- /dev/null
+++ b/tools/gen_spectral_library_index.py
@@ -0,0 +1,189 @@
+"""
+This script generates the index of Pickles and BPGS spectral files for all available
+spectral types. It shoudl not be needed for normal EXOSIMS operations, but is available
+if this index ever needs to be regenerated in the future.
+"""
+import pkg_resources
+from astropy.io import fits
+from MeanStars import MeanStars
+import os
+import numpy as np
+import pandas
+import json
+from EXOSIMS.Prototypes.TargetList import TargetList
+
+ms = MeanStars()
+specdict = {"O": 0, "B": 1, "A": 2, "F": 3, "G": 4, "K": 5, "M": 6}
+
+pickles_path = pkg_resources.resource_filename("EXOSIMS.TargetList", "dat_uvk")
+bpgs_path = pkg_resources.resource_filename("EXOSIMS.TargetList", "bpgs")
+
+
+# start with pickles atlas
+with fits.open(os.path.join(pickles_path, "pickles_uk.fits")) as hdulist:
+    pickles_spectra = hdulist[1].data
+
+all_spectral_files = {}
+used_files = []
+for j, spec in enumerate(pickles_spectra["SPTYPE"]):
+    # this atlas contains two redundant spectra that we don't need
+    if spec in ["M2.5V", "M10III"]:
+        continue
+    tmp = ms.specregex.match(spec)
+    if tmp:
+        specclass = list(tmp.groups())
+
+        # files with two character subclasses actually represent ranges
+        if len(specclass[1]) > 1:
+            subclass = range(int(specclass[1][0]), int(specclass[1][1]) + 1)
+        else:
+            subclass = [int(specclass[1])]
+
+        # write all required entries to dict
+        for sc in subclass:
+            all_spectral_files[f"{specclass[0]}{sc}{specclass[2]}"] = {
+                "file": f"{pickles_spectra['FILENAME'][j]}.fits",
+                "specclass": [
+                    specclass[0],
+                    int(sc),
+                    specclass[2],
+                    specdict[specclass[0]] * 10 + sc,
+                ],
+            }
+        used_files.append(f"{pickles_spectra['FILENAME'][j]}.fits")
+
+# identify files that can be removed
+unused_files = [
+    os.path.join(pickles_path, fn)
+    for fn in list(
+        set([f"{f}.fits" for f in pickles_spectra["FILENAME"]]) - set(used_files)
+    )
+]
+
+# now bpgs
+bpgs_spectra = pandas.read_table(
+    os.path.join(bpgs_path, "BPGS_README"),
+    delim_whitespace=True,
+    skiprows=3,
+    names=["file", "target", "type"],
+)
+# throw away those without spectral types
+unused_files += [
+    os.path.join(
+        bpgs_path,
+        f"{fn.split('.')[0]}.fits",
+    )
+    for fn in bpgs_spectra.loc[bpgs_spectra["type"].isnull(), "file"].values
+]
+bpgs_spectra = bpgs_spectra.loc[bpgs_spectra["type"].notnull()]
+
+# find new spectral types
+tmp = []
+for t in bpgs_spectra["type"].values:
+    if ms.specregex.match(t):
+        tmp.append(t)
+newspecs = list(set(tmp) - set(all_spectral_files.keys()))
+# remove any non-standard or supergiants types
+newspecs = [n for n in newspecs if (n.endswith("I") or n.endswith("V"))]
+tmp = np.array(tmp)
+
+used_files2 = []
+for s in newspecs:
+    # in cases where there are multiple matches (types G6IV and A1V), pick the last
+    # available entry
+    row = bpgs_spectra.loc[bpgs_spectra["type"] == s].iloc[-1]
+    specclass = list(ms.specregex.match(row.type).groups())
+    specclass[1] = int(specclass[1])
+
+    all_spectral_files[s] = {
+        "file": f"{row.file.split('.')[0]}.fits",
+        "specclass": specclass
+        + [
+            specdict[specclass[0]] * 10 + specclass[1],
+        ],
+    }
+    used_files2.append(row.file)
+
+unused_files += [
+    os.path.join(
+        bpgs_path,
+        f"{fn.split('.')[0]}.fits",
+    )
+    for fn in list(set(bpgs_spectra["file"].values) - set(used_files2))
+]
+
+# # sort final dict
+# all_spectral_files = dict(sorted(all_spectral_files.items(), key=lambda item: item[0]))
+
+# write final index to disk
+with open(
+    os.path.join(
+        pkg_resources.resource_filename("EXOSIMS", "TargetList"),
+        "spectral_catalog_index.json",
+    ),
+    "w",
+) as outfile:
+    json.dump(all_spectral_files, outfile)
+
+# remove unused files from directories
+for f in unused_files:
+    if os.path.exists(f):
+        print(f"Removing {f}")
+        os.remove(f)
+
+# some final consistency checking
+with open(
+    os.path.join(
+        pkg_resources.resource_filename("EXOSIMS", "TargetList"),
+        "spectral_catalog_index.json",
+    ),
+    "r",
+) as f:
+    specindex = json.load(f)
+
+for s in specindex:
+    if specindex[s]["file"].startswith("pickles"):
+        filepath = os.path.join(pickles_path, specindex[s]["file"])
+    else:
+        filepath = os.path.join(bpgs_path, specindex[s]["file"])
+    assert os.path.exists(filepath)
+
+specclasses = np.zeros((len(specindex), 4), dtype=object)
+for j, s in enumerate(specindex):
+    specclasses[j] = specindex[s]["specclass"]
+
+# check missing from EXOCAT
+specs0 = {
+    "modules": {
+        "PlanetPopulation": " ",
+        "StarCatalog": "EXOCAT1",
+        "OpticalSystem": " ",
+        "ZodiacalLight": " ",
+        "BackgroundSources": " ",
+        "PlanetPhysicalModel": " ",
+        "Observatory": " ",
+        "TimeKeeping": " ",
+        "PostProcessing": " ",
+        "Completeness": " ",
+        "TargetList": " ",
+        "SimulatedUniverse": " ",
+        "SurveySimulation": " ",
+        "SurveyEnsemble": " ",
+    },
+    "scienceInstruments": [{"name": "imager"}],
+    "starlightSuppressionSystems": [{"name": "coronagraph"}],
+}
+
+TL = TargetList(**specs0)
+uspecs = np.unique(TL.Spec)
+missing_specs = [us for us in uspecs if us not in specindex]
+
+# show closest match
+for s in missing_specs:
+    tmp = ms.specregex.match(s).groups()
+    tmp2 = specdict[tmp[0]] * 10 + float(tmp[1])
+    # filter by luminosity class
+    tmp3 = specclasses[specclasses[:, 2] == tmp[2]]
+    # next get the closest numerical spectral class representation
+    row = tmp3[np.argmin(np.abs(tmp3[:,3] - tmp2))]
+    print(f"{s} matched to {row[0]}{row[1]}{row[2]}")


Test patch:
diff --git a/tests/OpticalSystem/test_OpticalSystem.py b/tests/OpticalSystem/test_OpticalSystem.py
index c998f235b..0c7e427ad 100755
--- a/tests/OpticalSystem/test_OpticalSystem.py
+++ b/tests/OpticalSystem/test_OpticalSystem.py
@@ -37,12 +37,13 @@ def setUp(self):
             self.TL = TargetList(ntargs=10, **copy.deepcopy(self.spec))
 
         modtype = getattr(OpticalSystem, "_modtype")
+        whitelist = ["Nemati_2019"]
         pkg = EXOSIMS.OpticalSystem
         self.allmods = [get_module(modtype)]
         for loader, module_name, is_pkg in pkgutil.walk_packages(
             pkg.__path__, pkg.__name__ + "."
         ):
-            if not is_pkg:
+            if (not is_pkg) and (module_name.split(".")[-1] not in whitelist):
                 mod = get_module(module_name.split(".")[-1], modtype)
                 self.assertTrue(
                     mod._modtype is modtype, "_modtype mismatch for %s" % mod.__name__
@@ -54,7 +55,7 @@ def tearDown(self):
 
     def test_Cp_Cb_Csp(self):
         """
-        Sanity check Cp_Cb_Csp calculations.
+        Consistency check Cp_Cb_Csp calculations.
         """
 
         for mod in self.allmods:
@@ -109,7 +110,7 @@ def test_Cp_Cb_Csp(self):
 
     def test_calc_intTime(self):
         """
-        Check calc_intTime i/o only
+        Check calc_intTime i/o and basic consistency
         """
 
         for mod in self.allmods:
@@ -118,19 +119,34 @@ def test_calc_intTime(self):
 
             obj = mod(**copy.deepcopy(self.spec))
 
-            # first check, infinite dMag should give zero C_p
-            intTime = obj.calc_intTime(
+            # inttime for 0 dMag
+            intTime0 = obj.calc_intTime(
                 self.TL,
                 np.arange(self.TL.nStars),
                 np.array([0] * self.TL.nStars) / (u.arcsec**2.0),
                 np.array([0] * self.TL.nStars) / (u.arcsec**2.0),
-                np.ones(self.TL.nStars) * self.TL.int_dMag,
+                np.zeros(self.TL.nStars),
                 np.array(self.TL.int_WA.value) * self.TL.int_WA.unit,
                 obj.observingModes[0],
             )
+            self.assertEqual(len(intTime0), self.TL.nStars)
 
-            self.assertEqual(len(intTime), self.TL.nStars)
+            # inttime for 1 dMag
+            intTime1 = obj.calc_intTime(
+                self.TL,
+                np.arange(self.TL.nStars),
+                np.array([0] * self.TL.nStars) / (u.arcsec**2.0),
+                np.array([0] * self.TL.nStars) / (u.arcsec**2.0),
+                np.ones(self.TL.nStars),
+                np.array(self.TL.int_WA.value) * self.TL.int_WA.unit,
+                obj.observingModes[0],
+            )
+            self.assertTrue(
+                np.all(intTime1 >= intTime0),
+                "dMag=1 produces shorter integration times than dMag=0",
+            )
 
+    @unittest.skip("Redundant with test_intTime_dMag_roundtrip")
     def test_calc_dMag_per_intTime(self):
         """
         Check calc_dMag_per_intTime i/o
@@ -164,10 +180,8 @@ def test_intTime_dMag_roundtrip(self):
         calc_dMag_per_intTime give equivalent results
         """
 
-        exclude_mods = []
-
         # modules which do not calculate dMag from intTime
-        whitelist = ["OpticalSystem", "KasdinBraems"]
+        whitelist = ["KasdinBraems"]
 
         # set up values
         fZ = np.array([self.TL.ZodiacalLight.fZ0.value] * self.TL.nStars) / (
@@ -178,9 +192,6 @@ def test_intTime_dMag_roundtrip(self):
         )
 
         for mod in self.allmods:
-            if mod.__name__ in exclude_mods:
-                continue
-
             if mod.__name__ in whitelist:
                 continue
             # Because int_dMag depends on the OpticalSystem module we need to
@@ -190,7 +201,8 @@ def test_intTime_dMag_roundtrip(self):
             TL = TargetList(ntargs=10, **tmpspec)
 
             obj = TL.OpticalSystem
-            dMags1 = np.random.randn(TL.nStars) + TL.int_dMag
+            # make sure all dMags are lower than the saturation dMag
+            dMags1 = TL.saturation_dMag - np.random.rand(TL.nStars) * 2
 
             WA = np.array(self.TL.int_WA.value) * self.TL.int_WA.unit
             # integration times from dMags1
@@ -203,19 +215,12 @@ def test_intTime_dMag_roundtrip(self):
                 intTime1, TL, np.arange(TL.nStars), fZ, fEZ, WA, obj.observingModes[0]
             )
 
-            # intTime from dMags2
-            intTime2 = obj.calc_intTime(
-                TL, np.arange(TL.nStars), fZ, fEZ, dMags2, WA, obj.observingModes[0]
-            )
-
+            # check that all times were feasible
             useful_inds = ~np.isnan(intTime1)
-            self.assertTrue(np.sum(useful_inds) > 0)
+            self.assertTrue(np.all(useful_inds))
+
             # ensure dMags match up roundtrip
             self.assertTrue(np.allclose(dMags1[useful_inds], dMags2[useful_inds]))
-            # ensure intTimes match up roundtrip
-            self.assertTrue(
-                np.allclose(intTime1[useful_inds].value, intTime2[useful_inds].value)
-            )
 
     def test_ddMag_dt(self):
         """
@@ -240,6 +245,57 @@ def test_ddMag_dt(self):
 
             self.assertEqual(ddMag.shape, np.arange(self.TL.nStars).shape)
 
+    def calc_saturation_dMag(self):
+        """
+        Check calc_saturation_dMag i/o and basic consistency
+        """
+
+        for mod in self.allmods:
+            if "calc_saturation_dMag" not in mod.__dict__:
+                continue
+
+            obj = mod(**copy.deepcopy(self.spec))
+
+            sat_dMag = obj.calc_saturation_dMag(
+                self.TL,
+                np.arange(self.TL.nStars),
+                np.array([0] * self.TL.nStars) / (u.arcsec**2.0),
+                np.array([0] * self.TL.nStars) / (u.arcsec**2.0),
+                np.array(self.TL.int_WA.value) * self.TL.int_WA.unit,
+                obj.observingModes[0],
+            )
+            self.assertEqual(len(sat_dMag), self.TL.nStars)
+
+            # inttime at saturation
+            intTimesat = obj.calc_intTime(
+                self.TL,
+                np.arange(self.TL.nStars),
+                np.array([0] * self.TL.nStars) / (u.arcsec**2.0),
+                np.array([0] * self.TL.nStars) / (u.arcsec**2.0),
+                sat_dMag,
+                np.array(self.TL.int_WA.value) * self.TL.int_WA.unit,
+                obj.observingModes[0],
+            )
+            self.assertTrue(
+                np.all(np.isnan(intTimesat)),
+                "saturation dMag leads to finite integration time",
+            )
+
+            # inttime at 0.9*saturation
+            intTimenonsat = obj.calc_intTime(
+                self.TL,
+                np.arange(self.TL.nStars),
+                np.array([0] * self.TL.nStars) / (u.arcsec**2.0),
+                np.array([0] * self.TL.nStars) / (u.arcsec**2.0),
+                sat_dMag * 0.9,
+                np.array(self.TL.int_WA.value) * self.TL.int_WA.unit,
+                obj.observingModes[0],
+            )
+            self.assertTrue(
+                np.all(~np.isnan(intTimenonsat)),
+                "< saturation dMag leads to NaN integration time",
+            )
+
     def test_str(self):
         """
         Test __str__ method, for full coverage and check that all modules have
@@ -251,8 +307,6 @@ def test_str(self):
             "shapeFac",
             "pupilDiam",
             "intCutoff",
-            "ref_dMag",
-            "ref_Time",
             "pupilArea",
             "haveOcculter",
             "IWA",
diff --git a/tests/Prototypes/test_OpticalSystem.py b/tests/Prototypes/test_OpticalSystem.py
index 8dd92990e..5900fcde3 100644
--- a/tests/Prototypes/test_OpticalSystem.py
+++ b/tests/Prototypes/test_OpticalSystem.py
@@ -402,7 +402,6 @@ def test_init_owa_inf(self):
             for syst in our_specs["starlightSuppressionSystems"]:
                 syst["OWA"] = 0
             optsys = self.fixture(**deepcopy(our_specs))
-            self.assertTrue(np.isposinf(optsys.OWA.value))
             for syst in optsys.starlightSuppressionSystems:
                 self.assertTrue(np.isposinf(syst["OWA"].value))
         # repeat, but allow the special value to propagate up
@@ -412,7 +411,8 @@ def test_init_owa_inf(self):
             for syst in our_specs["starlightSuppressionSystems"]:
                 syst["OWA"] = 0
             optsys = self.fixture(**deepcopy(our_specs))
-            self.assertTrue(np.isposinf(optsys.OWA.value))
+            for syst in optsys.starlightSuppressionSystems:
+                self.assertTrue(np.isposinf(syst["OWA"].value))
 
     def test_init_iwa_owa(self):
         r"""Test of initialization and __init__ -- IWA, OWA.
@@ -430,7 +430,7 @@ def test_init_iwa_owa(self):
                 optsys = self.fixture(**deepcopy(our_specs))
         for specs in [specs_default, specs_simple, specs_multi]:
             # various settings of sub-object IWA and OWA
-            for IWA, OWA in zip([1, 1, 10, 10, 20, 20], [5, 15, 5, 15, 5, 15]):
+            for IWA, OWA in zip([0.1, 0.1, 1, 1, 2, 2], [0.5, 1.5, 0.5, 1.5, 0.5, 1.5]):
                 # the input dict is modified in-place -- so copy it
                 our_specs = deepcopy(specs)
                 # set sub-object IWA and OWA
diff --git a/tests/Prototypes/test_SurveySimulation.py b/tests/Prototypes/test_SurveySimulation.py
index 37ce313eb..57ec88c63 100644
--- a/tests/Prototypes/test_SurveySimulation.py
+++ b/tests/Prototypes/test_SurveySimulation.py
@@ -80,7 +80,7 @@ def test_reset_sim(self):
         """
 
         with RedirectStreams(stdout=self.dev_null):
-            sim = self.fixture(SimpleScript)
+            sim = self.fixture(SimpleScript, int_dMag=20)
             sim.run_sim()
 
         self.assertGreater(len(sim.DRM), 0)
diff --git a/tests/Prototypes/test_TargetList.py b/tests/Prototypes/test_TargetList.py
index 57657fc23..2db834445 100644
--- a/tests/Prototypes/test_TargetList.py
+++ b/tests/Prototypes/test_TargetList.py
@@ -252,9 +252,11 @@ def test_fillPhotometry(self):
         Filling in photometry should result in no nulls in Imag
         """
 
-        self.getTL(addkeys={"fillPhotometry": True})
+        self.getTL(addkeys={"fillPhotometry": True, "fillMissingBandMags": True})
 
         self.assertTrue(self.targetlist.fillPhotometry)
+        self.assertTrue(self.targetlist.fillMissingBandMags)
+
         self.assertTrue(
             np.all(self.targetlist.Imag != 0)
             and np.all(~np.isnan(self.targetlist.Imag))
diff --git a/tests/SurveySimulation/test_SurveySimulation.py b/tests/SurveySimulation/test_SurveySimulation.py
index 6f92900fb..82a5bea89 100644
--- a/tests/SurveySimulation/test_SurveySimulation.py
+++ b/tests/SurveySimulation/test_SurveySimulation.py
@@ -319,26 +319,27 @@ def test_next_target(self):
                                 sim.OpticalSystem.observingModes[0],
                                 sim.OpticalSystem.observingModes[0],
                             )
-                        self.assertIsInstance(
-                            occ_sInd,
-                            (int, np.int8, np.int16, np.int32, np.int64),
-                            "occ_sInd is not an integer for %s" % mod.__name__,
-                        )
-                        self.assertEqual(
-                            occ_sInd - int(occ_sInd),
-                            0,
-                            "occ_sInd is not an integer for %s" % (mod.__name__),
-                        )
-                        self.assertGreaterEqual(
-                            occ_sInd,
-                            0,
-                            "occ_sInd is not a valid index for %s" % mod.__name__,
-                        )
-                        self.assertLess(
-                            occ_sInd,
-                            sim.TargetList.nStars,
-                            "occ_sInd is not a valid index for %s" % mod.__name__,
-                        )
+                        if occ_sInd is not None:
+                            self.assertIsInstance(
+                                occ_sInd,
+                                (int, np.int8, np.int16, np.int32, np.int64),
+                                "occ_sInd is not an integer for %s" % mod.__name__,
+                            )
+                            self.assertEqual(
+                                occ_sInd - int(occ_sInd),
+                                0,
+                                "occ_sInd is not an integer for %s" % (mod.__name__),
+                            )
+                            self.assertGreaterEqual(
+                                occ_sInd,
+                                0,
+                                "occ_sInd is not a valid index for %s" % mod.__name__,
+                            )
+                            self.assertLess(
+                                occ_sInd,
+                                sim.TargetList.nStars,
+                                "occ_sInd is not a valid index for %s" % mod.__name__,
+                            )
                 elif (
                     "linearJScheduler_DDPC" in mod.__name__
                     or "linearJScheduler_3DDPC" in mod.__name__
@@ -383,22 +384,25 @@ def test_next_target(self):
 
                 # result index is a scalar numpy ndarray, that is a valid integer
                 # in a valid range
-                self.assertIsInstance(
-                    sInd,
-                    (int, np.int8, np.int16, np.int32, np.int64),
-                    "sInd is not an integer for %s" % mod.__name__,
-                )
-                self.assertEqual(
-                    sInd - int(sInd), 0, "sInd is not an integer for %s" % mod.__name__
-                )
-                self.assertGreaterEqual(
-                    sInd, 0, "sInd is not a valid index for %s" % mod.__name__
-                )
-                self.assertLess(
-                    sInd,
-                    sim.TargetList.nStars,
-                    "sInd is not a valid index for %s" % mod.__name__,
-                )
+                if sInd is not None:
+                    self.assertIsInstance(
+                        sInd,
+                        (int, np.int8, np.int16, np.int32, np.int64),
+                        "sInd is not an integer for %s" % mod.__name__,
+                    )
+                    self.assertEqual(
+                        sInd - int(sInd),
+                        0,
+                        "sInd is not an integer for %s" % mod.__name__,
+                    )
+                    self.assertGreaterEqual(
+                        sInd, 0, "sInd is not a valid index for %s" % mod.__name__
+                    )
+                    self.assertLess(
+                        sInd,
+                        sim.TargetList.nStars,
+                        "sInd is not a valid index for %s" % mod.__name__,
+                    )
 
                 # resulting DRM is a dictionary -- contents unimportant
                 self.assertIsInstance(
@@ -767,11 +771,12 @@ def test_observation_characterization(self):
                                 "char SNR less than required for %s" % mod.__name__,
                             )
 
-                self.assertLessEqual(
-                    intTime,
-                    sim.OpticalSystem.intCutoff,
-                    "char intTime greater than cutoff for %s" % mod.__name__,
-                )
+                if intTime is not None:
+                    self.assertLessEqual(
+                        intTime,
+                        sim.OpticalSystem.intCutoff,
+                        "char intTime greater than cutoff for %s" % mod.__name__,
+                    )
 
     def test_calc_signal_noise(self):
         r"""Test calc_signal_noise method.
diff --git a/tests/TestSupport/test-scripts/simplest.json b/tests/TestSupport/test-scripts/simplest.json
index e78b10ec5..f2a772179 100644
--- a/tests/TestSupport/test-scripts/simplest.json
+++ b/tests/TestSupport/test-scripts/simplest.json
@@ -6,32 +6,22 @@
   "minComp": 0.1,
   "pupilDiam": 2.37,
   "obscurFac": 0.2,
-  "dMagLim": 25,
   "intCutoff": 100,
   "ppFact": 0.1,
-  "EZ_distribution": "nominal_maxL_distribution.fits",
   "scienceInstruments": [
     {
       "name": "imaging-EMCCD",
-      "type": "imaging-EMCCD",
-      "lam": 565,
-      "BW": 0.10,
       "QE": 0.88,
-      "CIC": 0.0013,
-      "sread": 16,
-      "ENF": 1.414,
-      "Gem": 500
+      "sread": 16
     }
   ],
   "starlightSuppressionSystems": [
     {
       "name": "internal-imaging-HLC",
-      "type": "internal-imaging-HLC",
       "IWA": 0.1,
       "OWA": 0,
-      "throughput": 1,
-      "contrast": 1,
-      "PSF": 1
+      "core_mean_intensity": 1e-14,
+      "core_platescale": 0.333333
     }
   ],
   "modules": {
diff --git a/tests/TestSupport/test-scripts/simplest_3DDPC.json b/tests/TestSupport/test-scripts/simplest_3DDPC.json
index 1f54bbd16..95cf8fae6 100644
--- a/tests/TestSupport/test-scripts/simplest_3DDPC.json
+++ b/tests/TestSupport/test-scripts/simplest_3DDPC.json
@@ -10,7 +10,7 @@
   "intCutoff": 100,
   "ppFact": 0.1,
   "EZ_distribution": "nominal_maxL_distribution.fits",
-
+  "core_contrast": 1e-12,
   "settlingTime": 0.042, 
   "thrust": 1040,
   "slewIsp": 3000,
diff --git a/tests/TestSupport/test-scripts/simplest_occ.json b/tests/TestSupport/test-scripts/simplest_occ.json
index 1ff154c29..d8e79a044 100644
--- a/tests/TestSupport/test-scripts/simplest_occ.json
+++ b/tests/TestSupport/test-scripts/simplest_occ.json
@@ -10,7 +10,7 @@
   "intCutoff": 100,
   "ppFact": 0.1,
   "EZ_distribution": "nominal_maxL_distribution.fits",
-
+  "core_contrast": 1e-12,
   "settlingTime": 0.042, 
   "thrust": 1040,
   "slewIsp": 3000,
diff --git a/tests/test_MissionSim.py b/tests/test_MissionSim.py
index cd2a67464..405ebf905 100644
--- a/tests/test_MissionSim.py
+++ b/tests/test_MissionSim.py
@@ -49,7 +49,6 @@ class TestMissionSimMethods(unittest.TestCase):
     def setUp(self):
         # print '[setup] ',
         self.fixture = MissionSim
-
         # allow the chatter on stdout during object creation to be suppressed
         self.dev_null = open(os.devnull, "w")
 
diff --git a/tests/util/test_Samplers.py b/tests/util/test_Samplers.py
index bf336a977..c60032e05 100644
--- a/tests/util/test_Samplers.py
+++ b/tests/util/test_Samplers.py
@@ -16,7 +16,7 @@ def setUp(self):
         self.mods = [RS, ITS]
 
     def tearDown(self):
-        pass
+        self.dev_null.close()
 
     def test_simpSample(self):
         """Test samplers using KS-statistic for two continuous distributions