# Finding fiducial magnitudes for $t_{\mbox{eff}}$

## Introduction

$t_{\mbox{eff}}$ is a measure of depth, related to limiting magnitude as:

\begin{align}
t_{\mbox{eff}} & = 10^{\frac{4}{5}(m_\circ - m_{\mbox{lim}})} \\
m_{\mbox{lim}} & = m_\circ + 1.25 \log t_{\mbox{eff}}
\end{align}

such limiting magnitude varies with $t_{\mbox{eff}}$ in the same way as it varies with exposure time.

The constant $m_\circ$ provides a reference point, defining at what magnitude $t_{\mbox{eff}} = 1$, analogous to the constant reference magnitude in the definition of a magnitude system: accumulated $t_{\mbox{eff}}$ represents the progress made toward reaching a target coadd limiting magnitude, as measured in exposure time under constant conditions. If $m_\circ$ is the typical limiting magnitude for the instrument under real conditions, the difference between the accumulated $t_{\mbox{eff}}$ and the $t_{\mbox{eff}}$ corresponding to the target limiting magnitude is a good approximation of the total exposure time needed to attain the target limiting magnitude. If $m_\circ$ is different from that typical for the instrument, then the remaining $t_{\mbox{eff}}$ will be related to the remaining needed exposure time by a scaling factor.

A reasonable fiducial magnitude for a given instrument is to define for $t_{\mbox{eff}}=1$ for optimally pointing images under typical conditions: taken at zenith, during dark time, in typical seeing. With this fiducial magnitude, the remaining needed $t_{\mbox{eff}}$ matches the remaining exposure time if optimally scheduled, and the target limiting magnitude varies with the zenith distance at transit.

`opsim` provides a function, `m5_flat_sed`, to calculate the limiting magnitude (including various throughputs, etc.) under a given set of conditions (seeing, airmass, and sky brightness) using parameters provided by system engineering.

The initial section of this notebook uses the mean log Fried parameter (according to the model described in RTN-022) to deteremine the seeing, the sky brightness model used by `opsim` in full dark conditions, zenith pointing (airmass=1), and `m5_flat_sed` to compute fiducial zero points for $t_{\mbox{eff}}$.

The later section of the notebook generates magnitude using different assumptions about the throughput, seeing, sky brightness, and airmass to trace the differences between magnitudes derived from the SRD and estimates based on the latest assumptions used by `opsim`.

For discussion related to this notebook and the resoning behind it, see the conversations in the `survey-strategy-team` LSSTC slack channel starting April 9, 2021 (around [here](https://lsstc.slack.com/archives/G6MP1HTDW/p1617997908069200)), through April 20th.

## Notebook setup

### Logging

Do it first so I can use logging to track how long imports take.

```python
import logging
logging.basicConfig(format='%(asctime)s %(message)s')
logger = logging.getLogger(__name__)
logger.setLevel('DEBUG')
logger.info("Starting")
```

    2021-07-07 10:16:50,980 Starting

### Imports

```python
import os
import os.path
import copy
import pickle
import urllib
from os import path
from tempfile import TemporaryDirectory
```

```python
logger.debug("Loading common modules")
import pandas as pd
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
import sqlite3
```

    2021-07-07 10:16:51,019 Loading common modules

```python
logger.debug("Loading general astronomy modules")
import healpy
import palpy
import astropy
import astropy.coordinates
from astropy.coordinates import EarthLocation
from astropy.time import Time
import astropy.units as u
```

    2021-07-07 10:16:51,465 Loading general astronomy modules

```python
logger.debug("Loading Rubin Observatory modules")
import rubin_sim
import rubin_sim.site_models
from rubin_sim.utils import m5_flat_sed
from rubin_sim.site_models import SeeingData, SeeingModel
from rubin_sim.site_models import CloudData
from rubin_sim.skybrightness_pre import SkyModelPre
```

    2021-07-07 10:16:51,843 Loading Rubin Observatory modules

### Plot setup

```python
mpl.rcParams['figure.figsize'] = (8, 5)
plt.style.use('ggplot')
np.random.seed(6563)
```

### Constants

```python
sky_model = SkyModelPre()
site = EarthLocation.of_site('Cerro Pachon')
mean_log_r0 = -0.9163 ;# See RTN-022, table 1
mjd_sample_rate = 0.01 ;# Every 0.01 days = every 14.4 minutes
original_median_seeing500 = 0.62
data_dir = '.'
opsim_origin = 'https://lsst.ncsa.illinois.edu/sim-data/sims_featureScheduler_runs1.7/baseline/baseline_nexp2_v1.7_10yrs.db'
```

```python
bands = pd.DataFrame({
    'band': ('u', 'g', 'r', 'i', 'z', 'y'),
    'min_wavelength': (350.0, 400.0, 552.0, 691.0, 818.0, 949.0),
    'max_wavelength': (400.0, 552.0, 691.2, 818.0, 922.0, 1060.0)}
).set_index('band', drop=False)
bands
```

      band
      min_wavelength
      max_wavelength

      band

      u
      u
      350.0
      400.0

      g
      g
      400.0
      552.0

      r
      r
      552.0
      691.2

      i
      i
      691.0
      818.0

      z
      z
      818.0
      922.0

      y
      y
      949.0
      1060.0

```python
sun_cache_fname = 'sun_cache.pickle'
moon_cache_fname = 'moon_cache.pickle'
sky_cache_fname = 'sky_cache.pickle'
sky_x12_cache_fname = 'sky_x12_cache.pickle'
visit_cache = 'opsim_visits.h5'
```

### Retrieving data

```python
if path.isfile(opsim_origin) or 'opsim_fname' in locals():
    # Check for opsim_fname in locals so that if we rerun the notebook without restarting the kernel
    # it will not re-download the data
    opsim_fname = opsim_origin
else:
    opsim_data_dir = TemporaryDirectory(dir=data_dir)
    opsim_fname = path.join(opsim_data_dir.name, opsim_origin.split('/')[-1])
    logger.info("Downloading %s to %s", opsim_origin, opsim_fname)
    opsim_db_file = urllib.request.urlretrieve(opsim_origin, opsim_fname)
    logger.info("Download complete")

    print(f"Opsim data file downloaded to\n\t{opsim_fname}\nwhich will be deleted when the notebook is restarted or closed.")
```

    2021-07-07 10:16:52,167 Downloading https://lsst.ncsa.illinois.edu/sim-data/sims_featureScheduler_runs1.7/baseline/baseline_nexp2_v1.7_10yrs.db to ./tmpuuwbabty/baseline_nexp2_v1.7_10yrs.db
    2021-07-07 10:16:56,117 Download complete

    Opsim data file downloaded to
    	./tmpuuwbabty/baseline_nexp2_v1.7_10yrs.db
    which will be deleted when the notebook is restarted or closed.

```python
opsim_name = os.path.splitext(os.path.split(opsim_fname)[-1])[0]
```

### Configuration

```python
pd.set_option('display.float_format', lambda x: '%.2f' % x)
```

## Seeing

The seeing model of RTN-022 provides a mean log r0 (Fried parameter), which provides an average seeing. Begin by transforming that to a von Karman seeing with an outer scale of 30m:

```python
def vk_seeing(r0, outer_scale=30.0, wavelength=5.0e-7):
    """Calculate the seeing using a von Karman model.

    See Tokovinin 2002PASP..114.1156T

    Args:
        r0: the Fried parameter, in meters
        outer_scale: the von Karman outer scale, in meters
        wavelength: the wavelength of light, in meters

    Returns:
        The PSF FWHM, in arcseconds
    """
    # Calculate the DIMM estimate of the seeing using the Kolmogorov model,
    # using eqn 5 from Tokovinin 2002PASP..114.1156T eqn 5
    kol_seeing = 0.98*wavelength/r0

    # Calculate the correction factor required to convert the Kolmogorov model
    # seeing to the von Karman model seeing,
    # using eqn 19 of Tokovinin 2002PASP..114.1156T
    vk_correction2 = 1.0 - 2.183*np.power(r0/outer_scale, 0.356)

    # Apply the correction factor
    seeing_rad = kol_seeing * np.sqrt(vk_correction2)

    # Convert to arcseconds
    seeing = np.degrees(seeing_rad)*(60.0*60.0)

    return seeing

seeing500 = np.round(vk_seeing(10**mean_log_r0, outer_scale=30), 2)
seeing500
```

    0.69

Now, apply the `opsim` seeing model:

```python
seeing_model = SeeingModel()
seeing = pd.Series(seeing_model(seeing500, 1.0)['fwhmEff'], index=bands.band)
seeing
```

    band
    u   1.01
    g   0.95
    r   0.89
    i   0.86
    z   0.83
    y   0.81
    dtype: float64

## Sky

Sample the sky at intervals for all dark time in the survey:

```python
%%time
mjd_range = (astropy.time.Time('2022-10-01T16:00:00Z').mjd, astropy.time.Time('2032-10-01T16:00:00Z').mjd)
all_mjds = np.arange(*mjd_range, mjd_sample_rate)
all_times = astropy.time.Time(all_mjds, scale='utc', format='mjd', location=site)

try:
    with open(sun_cache_fname, 'rb') as sun_cache:
        sun_coords = pickle.load(sun_cache)
    with open(moon_cache_fname, 'rb') as moon_cache:
        moon_coords = pickle.load(moon_cache)
except FileNotFoundError:
    sun_coords = astropy.coordinates.get_sun(all_times).transform_to(astropy.coordinates.AltAz(obstime=all_times, location=site))
    with open(sun_cache_fname, 'wb') as fp:
        pickle.dump(sun_coords, fp)

    moon_coords = astropy.coordinates.get_moon(all_times).transform_to(astropy.coordinates.AltAz(obstime=all_times, location=site))
    with open(moon_cache_fname, 'wb') as moon_cache:
        pickle.dump(moon_coords, moon_cache)

dark_idxs = np.logical_and(sun_coords.alt.deg < -18, moon_coords.alt.deg < -18)
mjds = all_mjds[dark_idxs]
times = all_times[dark_idxs]
```

    /data/des91.b/data/neilsen/anaconda/envs/rubin_sim/lib/python3.9/site-packages/erfa/core.py:154: ErfaWarning: ERFA function "dtf2d" yielded 1 of "dubious year (Note 6)"
      warnings.warn('ERFA function "{}" yielded {}'.format(func_name, wmsg),

    CPU times: user 39.9 ms, sys: 72.9 ms, total: 113 ms
    Wall time: 115 ms

Find the healpixels for the standard pointing (zenith) for the sampled times:

```python
def find_zenith_hpixs(mjd, site, nside, north=True):
    lst = Time(mjd, format='mjd', location=site).sidereal_time('apparent').to_value(u.degree)
    ra = lst
    decl = site.lat.deg
    hpix = healpy.ang2pix(nside, ra, decl, lonlat=True)
    return hpix
```

Actually calculate the sky brightnesses:

```python
def compute_zenith_mags(mjds, sky_model, **kwargs):
    zenith_hpixs = pd.DataFrame({'mjd': mjds,
                                 'hpix': find_zenith_hpixs(mjds, **kwargs)})
    mags = zenith_hpixs.apply(
        lambda x: pd.Series({k: m[0] for k, m in sky_model.returnMags(x.mjd, indx=np.array([x.hpix], dtype=int), badval=np.nan, zenith_mask=False).items()}),
        axis=1)
    mags['mjd'] = mjds
    return mags

if __debug__:
    df = compute_zenith_mags(np.arange(60000.7, 60001.7, 0.01), sky_model, site=site, nside=sky_model.nside)
    ax = None
    for band in bands.band:
        ax = df.plot('mjd', band, ax=ax)
```

```python
%%time
try:
    with open(sky_cache_fname, 'rb') as sky_cache:
        dark_sky_mags = pickle.load(sky_cache)
except FileNotFoundError:
    dark_sky_mags = compute_zenith_mags(mjds, sky_model=sky_model, site=site, nside=sky_model.nside)
    with open(sky_cache_fname, 'wb') as sky_cache:
        pickle.dump(dark_sky_mags, sky_cache)
```

    CPU times: user 2.16 ms, sys: 5.29 ms, total: 7.45 ms
    Wall time: 7.23 ms

Get the median dark sky in each band:

```python
median_dark_sky = dark_sky_mags.median()
median_dark_sky
```

    u        22.68
    g        22.11
    r        21.11
    i        20.39
    z        19.43
    y        18.63
    mjd   61710.15
    dtype: float64

## Calculate the fiducial magnitudes

```python
try:
    # Make sure we are using parameters as defined in the code.
    delattr(m5_flat_sed, 'Cm')
except AttributeError:
    pass

m0 = bands.copy()
m0.apply(
        lambda r: m5_flat_sed(r['band'], median_dark_sky[r['band']], seeing[r['band']], expTime=15, airmass=1, nexp=2), axis=1).round(2)
```

    band
    u   23.71
    g   24.67
    r   24.24
    i   23.82
    z   23.21
    y   22.40
    dtype: float64

# Tracing the origin of differences with the SRD

## SRD m5

Copy values from the SRD:

```python
m5 = pd.DataFrame(
    {'SRD spec': [23.9, 25.0, 24.7, 24.0, 23.3, 22.1],
     'SRD min': [23.4 ,24.6, 24.3, 23.6, 22.9, 21.7],
     'SRD stretch': [24.0, 25.1, 24.8, 24.1, 23.4, 22.2]},
    index=bands.band)
```

## Reproduce m5 from the overview paper

Table 2 from p. 26 of the [overview paper](https://arxiv.org/abs/0805.2366).

```python
overview_table2 = pd.DataFrame({'m_sky': [22.99, 22.26, 21.20, 20.48, 19.60, 18.61],
                                'theta': [0.81, 0.77, 0.73, 0.71, 0.69, 0.68],
                                'theta_eff': [0.92, 0.87, 0.83, 0.80, 0.79, 0.76],
                                'gamma': [0.038, 0.039, 0.039, 0.039, 0.039, 0.039],
                                'k_m': [0.491, 0.213, 0.126, 0.096, 0.069, 0.170],
                                'C_m': [23.09, 24.42, 24.44, 24.32, 24.16, 23.73],
                                'm5': [23.78, 24.81, 24.35, 23.92, 23.34, 22.45],
                                'Delta_C_m_infinity': [0.62, 0.18, 0.10, 0.07, 0.05, 0.04],
                                'Delta_C_m_2': [0.23, 0.08, 0.05, 0.03, 0.02, 0.02],
                                'Delta_m5': [0.21, 0.16, 0.14, 0.13, 0.13, 0.14],
                               }, index=bands.index)

overview_table2.T
```

      band
      u
      g
      r
      i
      z
      y

      m_sky
      22.99
      22.26
      21.20
      20.48
      19.60
      18.61

      theta
      0.81
      0.77
      0.73
      0.71
      0.69
      0.68

      theta_eff
      0.92
      0.87
      0.83
      0.80
      0.79
      0.76

      gamma
      0.04
      0.04
      0.04
      0.04
      0.04
      0.04

      k_m
      0.49
      0.21
      0.13
      0.10
      0.07
      0.17

      C_m
      23.09
      24.42
      24.44
      24.32
      24.16
      23.73

      m5
      23.78
      24.81
      24.35
      23.92
      23.34
      22.45

      Delta_C_m_infinity
      0.62
      0.18
      0.10
      0.07
      0.05
      0.04

      Delta_C_m_2
      0.23
      0.08
      0.05
      0.03
      0.02
      0.02

      Delta_m5
      0.21
      0.16
      0.14
      0.13
      0.13
      0.14

Call `m5_flat_sed` once to instantiate default parameters.

```python
try:
    # Make sure we are using parameters as defined in the code.
    delattr(m5_flat_sed, 'Cm')
except AttributeError:
    pass

_ = m5_flat_sed('u', 23, 1.0, 30.0, 1.0)
```

```python
m5_flat_sed.Cm = overview_table2.C_m.to_dict()
m5_flat_sed.dCm_infinity = overview_table2.Delta_C_m_infinity.to_dict()
m5_flat_sed.kAtm = overview_table2.k_m
m5_flat_sed.msky = overview_table2.m_sky
```

```python
m5['overview'] = overview_table2.reset_index().apply(
        lambda r: m5_flat_sed(r['band'], r['m_sky'], r['theta_eff'], expTime=30, airmass=1.0, nexp=1), axis=1).values
m5
```

      SRD spec
      SRD min
      SRD stretch
      overview

      band

      u
      23.90
      23.40
      24.00
      23.79

      g
      25.00
      24.60
      25.10
      24.81

      r
      24.70
      24.30
      24.80
      24.36

      i
      24.00
      23.60
      24.10
      23.92

      z
      23.30
      22.90
      23.40
      23.33

      y
      22.10
      21.70
      22.20
      22.45

Compare m5 as re-derived from the table using `m5_flat_sed` with the actual values from the table:

```python
m5['overview'] - overview_table2['m5']
```

    band
    u    0.01
    g    0.00
    r    0.01
    i   -0.00
    z   -0.01
    y   -0.00
    dtype: float64

Okay, so close, but maybe some rounding difference.

## Move to airmass of 1.2

See if we can reproduce the `theta_eff` from overview table 2 using `SeeingModel`, so we can use it to calculate the effective seeing at other airmasses.

```python
seeing_model = SeeingModel()
```

```python
test_eff_seeing = pd.DataFrame(
    {'table 2': overview_table2.theta_eff.values,
     'SeeingModel': seeing_model(original_median_seeing500, 1.0)['fwhmEff']},
index=bands.index)
test_eff_seeing['difference'] = test_eff_seeing['SeeingModel'] - test_eff_seeing['table 2']
test_eff_seeing
```

      table 2
      SeeingModel
      difference

      band

      u
      0.92
      0.93
      0.01

      g
      0.87
      0.87
      0.00

      r
      0.83
      0.83
      -0.00

      i
      0.80
      0.80
      -0.00

      z
      0.79
      0.77
      -0.02

      y
      0.76
      0.76
      -0.00

Pre-compute the effective seeing at airmass

```python
original_seeing_12 = pd.Series(seeing_model(original_median_seeing500, 1.2)['fwhmEff'], index=bands.index)
original_seeing_12
```

    band
    u   1.03
    g   0.98
    r   0.92
    i   0.89
    z   0.86
    y   0.85
    dtype: float64

```python
m5['airmass'] = overview_table2.reset_index().apply(
        lambda r: m5_flat_sed(r['band'], r['m_sky'], original_seeing_12[r['band']], expTime=30, airmass=1.2, nexp=1), axis=1).values
m5
```

      SRD spec
      SRD min
      SRD stretch
      overview
      airmass

      band

      u
      23.90
      23.40
      24.00
      23.79
      23.56

      g
      25.00
      24.60
      25.10
      24.81
      24.65

      r
      24.70
      24.30
      24.80
      24.36
      24.21

      i
      24.00
      23.60
      24.10
      23.92
      23.78

      z
      23.30
      22.90
      23.40
      23.33
      23.22

      y
      22.10
      21.70
      22.20
      22.45
      22.30

Compare change to airmass 1.2 to that reported in table 2:

```python
test_airmass12 = pd.DataFrame({'table 2 Delta_m5': overview_table2.Delta_m5,
                               'Delta_m5': m5['overview'] - m5['airmass']})
test_airmass12['diff'] = test_airmass12['Delta_m5'] - test_airmass12['table 2 Delta_m5']
test_airmass12
```

      table 2 Delta_m5
      Delta_m5
      diff

      band

      u
      0.21
      0.23
      0.02

      g
      0.16
      0.17
      0.01

      r
      0.14
      0.14
      0.00

      i
      0.13
      0.13
      0.00

      z
      0.13
      0.11
      -0.02

      y
      0.14
      0.15
      0.01

## Update to use throughput in `sims_utils`

Trigger reinitialzation of the parameters in `m5_flat_sed` to be defaults when called:

```python
del m5_flat_sed.Cm
```

```python
m5['throughput'] = overview_table2.reset_index().apply(
        lambda r: m5_flat_sed(r['band'], r['m_sky'], original_seeing_12[r['band']], expTime=30, airmass=1.2, nexp=1), axis=1).values
m5
```

      SRD spec
      SRD min
      SRD stretch
      overview
      airmass
      throughput

      band

      u
      23.90
      23.40
      24.00
      23.79
      23.56
      23.85

      g
      25.00
      24.60
      25.10
      24.81
      24.65
      24.73

      r
      24.70
      24.30
      24.80
      24.36
      24.21
      24.26

      i
      24.00
      23.60
      24.10
      23.92
      23.78
      23.83

      z
      23.30
      22.90
      23.40
      23.33
      23.22
      23.26

      y
      22.10
      21.70
      22.20
      22.45
      22.30
      22.34

## Switch to two exposures per visit

```python
m5['2exp'] = overview_table2.reset_index().apply(
        lambda r: m5_flat_sed(r['band'], r['m_sky'], original_seeing_12[r['band']], expTime=15, airmass=1.2, nexp=2), axis=1).values
m5
```

      SRD spec
      SRD min
      SRD stretch
      overview
      airmass
      throughput
      2exp

      band

      u
      23.90
      23.40
      24.00
      23.79
      23.56
      23.85
      23.63

      g
      25.00
      24.60
      25.10
      24.81
      24.65
      24.73
      24.65

      r
      24.70
      24.30
      24.80
      24.36
      24.21
      24.26
      24.22

      i
      24.00
      23.60
      24.10
      23.92
      23.78
      23.83
      23.80

      z
      23.30
      22.90
      23.40
      23.33
      23.22
      23.26
      23.24

      y
      22.10
      21.70
      22.20
      22.45
      22.30
      22.34
      22.32

Compare with the difference reported in overview table 2:

```python
pd.DataFrame({'calculated': m5['throughput'] - m5['2exp'],
              'overview': overview_table2['Delta_C_m_2'],
              'diff': overview_table2['Delta_C_m_2'] + m5['2exp'] - m5['throughput']})
```

      calculated
      overview
      diff

      band

      u
      0.22
      0.23
      0.01

      g
      0.08
      0.08
      -0.00

      r
      0.05
      0.05
      0.00

      i
      0.03
      0.03
      -0.00

      z
      0.02
      0.02
      -0.00

      y
      0.02
      0.02
      0.00

## Change to the latest seeing model

```python
seeing_airmass12 = pd.Series(seeing_model(seeing500, 1.2)['fwhmEff'], index=bands.band)
seeing_airmass12
```

    band
    u   1.12
    g   1.06
    r   1.00
    i   0.96
    z   0.93
    y   0.91
    dtype: float64

```python
m5['seeing'] = overview_table2.reset_index().apply(
        lambda r: m5_flat_sed(r['band'], r['m_sky'], seeing_airmass12[r['band']], expTime=15, airmass=1.2, nexp=2), axis=1).values
m5
```

      SRD spec
      SRD min
      SRD stretch
      overview
      airmass
      throughput
      2exp
      seeing

      band

      u
      23.90
      23.40
      24.00
      23.79
      23.56
      23.85
      23.63
      23.54

      g
      25.00
      24.60
      25.10
      24.81
      24.65
      24.73
      24.65
      24.56

      r
      24.70
      24.30
      24.80
      24.36
      24.21
      24.26
      24.22
      24.13

      i
      24.00
      23.60
      24.10
      23.92
      23.78
      23.83
      23.80
      23.72

      z
      23.30
      22.90
      23.40
      23.33
      23.22
      23.26
      23.24
      23.16

      y
      22.10
      21.70
      22.20
      22.45
      22.30
      22.34
      22.32
      22.24

## Change to the latest sky brightness model and airmass=1.2

```python
night_idxs = sun_coords.alt.deg < -14
night_mjds = all_mjds[night_idxs]
night_times = all_times[night_idxs]
```

```python
def compute_zd_from_airmass(airmass, a=470.0):
    mu = (1.0/airmass) + (1/(2.0*a))*((1.0/airmass) - airmass)
    zd = np.degrees(np.arccos(mu))
    return zd

if __debug__:
    # Bemporad values (empirical values for airmass as a function of zenith distance)
    assert compute_zd_from_airmass(1)==0
    assert np.round(compute_zd_from_airmass(1.154), 1)==30
    assert np.round(compute_zd_from_airmass(1.995), 1)==60
    assert np.round(compute_zd_from_airmass(2.904), 1)==70
    assert np.round(compute_zd_from_airmass(18.8))==88
```

```python
def compute_transiting_hpixs(mjd, airmass, site, nside, north=True):
    zd = compute_zd_from_airmass(airmass)
    lst = Time(mjd, format='mjd', location=site).sidereal_time('apparent').to_value(u.degree)
    ra = lst
    decl = site.lat.deg + zd if north else site.lat.degrees - zd
    hpix = healpy.ang2pix(nside, ra, decl, lonlat=True)
    return hpix
```

Actually calculate the sky brightnesses:

```python
def compute_transiting_mags(mjds, sky_model, **kwargs):
    transiting_hpixs = pd.DataFrame({'mjd': mjds,
                                     'hpix': compute_transiting_hpixs(mjds, **kwargs)})
    mags = transiting_hpixs.apply(
        lambda x: pd.Series({k: m[0] for k, m in sky_model.returnMags(x.mjd, indx=np.array([x.hpix], dtype=int), badval=np.nan, zenith_mask=False).items()}),
        axis=1)
    mags['mjd'] = mjds
    return mags

if __debug__:
    df = compute_transiting_mags(np.arange(60000.7, 60001.7, 0.01), sky_model, airmass=1.2, site=site, nside=sky_model.nside)
    ax = None
    for band in bands.band:
        ax = df.plot('mjd', band, ax=ax)
```

```python
%%time
try:
    with open(sky_x12_cache_fname, 'rb') as sky_cache:
        sky_mags_x12 = pickle.load(sky_cache)
except FileNotFoundError:
    sky_mags_x12 = compute_transiting_mags(night_mjds, sky_model=sky_model, airmass=1.2, site=site, nside=sky_model.nside)
    with open(sky_x12_cache_fname, 'wb') as sky_cache:
        pickle.dump(sky_mags_x12, sky_cache)
```

    CPU times: user 1.1 ms, sys: 6.1 ms, total: 7.2 ms
    Wall time: 4.78 ms

```python
sky_x12_mag_stats = sky_mags_x12.describe()
sky_x12_mag_stats
```

      u
      g
      r
      i
      z
      y
      mjd

      count
      146227.00
      146227.00
      146227.00
      146227.00
      146227.00
      146227.00
      148164.00

      mean
      21.32
      20.68
      20.05
      19.61
      18.92
      18.28
      61691.99

      std
      1.57
      1.62
      1.20
      0.84
      0.48
      0.26
      1054.35

      min
      17.75
      16.93
      16.85
      17.01
      17.08
      17.07
      59854.00

      25%
      19.85
      19.13
      19.04
      19.03
      18.70
      18.19
      60788.37

      50%
      22.45
      21.83
      20.87
      20.16
      19.22
      18.43
      61680.17

      75%
      22.56
      21.96
      20.95
      20.21
      19.25
      18.44
      62615.11

      max
      22.61
      22.07
      21.03
      20.26
      19.28
      18.47
      63506.39

The nominal total number of u, g, and r exposures is the same as the nominal total z and y.

If we assume u, g, and r are observed in the darkest 50% of time, the z and y in the brightest, and the i evenly split, then we get:

```python
typical_m_sky = pd.Series([
    sky_x12_mag_stats.loc['75%', 'u'],
    sky_x12_mag_stats.loc['75%', 'g'],
    sky_x12_mag_stats.loc['75%', 'r'],
    sky_x12_mag_stats.loc['50%', 'i'],
    sky_x12_mag_stats.loc['25%', 'z'],
    sky_x12_mag_stats.loc['25%', 'y']],
    index=bands.index
)
typical_m_sky
```

    band
    u   22.56
    g   21.96
    r   20.95
    i   20.16
    z   18.70
    y   18.19
    dtype: float64

```python
m5['sky'] = overview_table2.reset_index().apply(
        lambda r: m5_flat_sed(r['band'], typical_m_sky[r['band']], seeing_airmass12[r['band']], expTime=15, airmass=1.2, nexp=2), axis=1).values
m5
```

      SRD spec
      SRD min
      SRD stretch
      overview
      airmass
      throughput
      2exp
      seeing
      sky

      band

      u
      23.90
      23.40
      24.00
      23.79
      23.56
      23.85
      23.63
      23.54
      23.46

      g
      25.00
      24.60
      25.10
      24.81
      24.65
      24.73
      24.65
      24.56
      24.45

      r
      24.70
      24.30
      24.80
      24.36
      24.21
      24.26
      24.22
      24.13
      24.03

      i
      24.00
      23.60
      24.10
      23.92
      23.78
      23.83
      23.80
      23.72
      23.58

      z
      23.30
      22.90
      23.40
      23.33
      23.22
      23.26
      23.24
      23.16
      22.74

      y
      22.10
      21.70
      22.20
      22.45
      22.30
      22.34
      22.32
      22.24
      22.04

## Baseline median and mean

Look at what a baseline `opsim` simulation actually achieves.

```python
try:
    visits = pd.read_hdf(visit_cache, opsim_name)
except (FileNotFoundError, KeyError):
    with sqlite3.connect(opsim_fname) as con:
        visits = pd.read_sql_query('SELECT * FROM SummaryAllProps', con)
    visits.set_index('filter', drop=False, inplace=True)
    visits.index.name = 'band'
    visits.to_hdf(visit_cache, opsim_name)
```

```python
m5['b. median'] = visits.groupby('band').agg({'fiveSigmaDepth': 'median'}).loc[bands.index, 'fiveSigmaDepth'].T
m5['b. mean'] = visits.groupby('band').agg({'fiveSigmaDepth': 'mean'}).loc[bands.index, 'fiveSigmaDepth'].T
m5
```

      SRD spec
      SRD min
      SRD stretch
      overview
      airmass
      throughput
      2exp
      seeing
      sky
      b. median
      b. mean

      band

      u
      23.90
      23.40
      24.00
      23.79
      23.56
      23.85
      23.63
      23.54
      23.46
      23.39
      23.35

      g
      25.00
      24.60
      25.10
      24.81
      24.65
      24.73
      24.65
      24.56
      24.45
      24.44
      24.40

      r
      24.70
      24.30
      24.80
      24.36
      24.21
      24.26
      24.22
      24.13
      24.03
      24.00
      23.96

      i
      24.00
      23.60
      24.10
      23.92
      23.78
      23.83
      23.80
      23.72
      23.58
      23.43
      23.37

      z
      23.30
      22.90
      23.40
      23.33
      23.22
      23.26
      23.24
      23.16
      22.74
      22.78
      22.74

      y
      22.10
      21.70
      22.20
      22.45
      22.30
      22.34
      22.32
      22.24
      22.04
      22.00
      21.97

## Baseline mag from mean t_eff

```python
def compute_teff(ref_mags, mags=visits.fiveSigmaDepth):
    t_eff = 10**(0.8*(mags-ref_mags.loc[mags.index]))
    return t_eff
```

```python
m5['b. teff mean'] = m5['SRD spec'] + 1.25*np.log10(compute_teff(m5['SRD spec'], visits.fiveSigmaDepth).groupby('band').mean())
m5
```

      SRD spec
      SRD min
      SRD stretch
      overview
      airmass
      throughput
      2exp
      seeing
      sky
      b. median
      b. mean
      b. teff mean

      band

      u
      23.90
      23.40
      24.00
      23.79
      23.56
      23.85
      23.63
      23.54
      23.46
      23.39
      23.35
      23.45

      g
      25.00
      24.60
      25.10
      24.81
      24.65
      24.73
      24.65
      24.56
      24.45
      24.44
      24.40
      24.50

      r
      24.70
      24.30
      24.80
      24.36
      24.21
      24.26
      24.22
      24.13
      24.03
      24.00
      23.96
      24.05

      i
      24.00
      23.60
      24.10
      23.92
      23.78
      23.83
      23.80
      23.72
      23.58
      23.43
      23.37
      23.50

      z
      23.30
      22.90
      23.40
      23.33
      23.22
      23.26
      23.24
      23.16
      22.74
      22.78
      22.74
      22.84

      y
      22.10
      21.70
      22.20
      22.45
      22.30
      22.34
      22.32
      22.24
      22.04
      22.00
      21.97
      22.06

```python
print(m5)
```

          SRD spec  SRD min  SRD stretch  overview  airmass  throughput  2exp  \
    band
    u        23.90    23.40        24.00     23.79    23.56       23.85 23.63
    g        25.00    24.60        25.10     24.81    24.65       24.73 24.65
    r        24.70    24.30        24.80     24.36    24.21       24.26 24.22
    i        24.00    23.60        24.10     23.92    23.78       23.83 23.80
    z        23.30    22.90        23.40     23.33    23.22       23.26 23.24
    y        22.10    21.70        22.20     22.45    22.30       22.34 22.32

          seeing   sky  b. median  b. mean  b. teff mean
    band
    u      23.54 23.46      23.39    23.35         23.45
    g      24.56 24.45      24.44    24.40         24.50
    r      24.13 24.03      24.00    23.96         24.05
    i      23.72 23.58      23.43    23.37         23.50
    z      23.16 22.74      22.78    22.74         22.84
    y      22.24 22.04      22.00    21.97         22.06

## Plot how the magnitude limits change with each modification from the SRD

```python
m5_imp = m5[['overview', 'airmass', 'throughput', '2exp', 'seeing', 'sky']].T
m5_imp.index.rename('step', inplace=True)
m5_imp.reset_index(inplace=True)
fig, axes = plt.subplots(2, 3, figsize=(16, 12))
for band, ax in zip(bands.band, axes.flatten()):
    ax.plot(m5_imp.index, m5_imp[band], marker='o', linestyle=' ', color='darkblue')
    ax.plot(m5_imp.index, m5_imp[band], alpha=0.2, color='darkblue')
    ax.set_xticks(np.arange(len(m5_imp['step'])))
    ax.set_xticklabels(m5_imp['step'], rotation=20)
    ax.set_title(band)
    ax.axhline(m5.loc[band, 'SRD spec'], linestyle=':', color='k')
    ax.text(0, m5.loc[band, 'SRD spec'], 'SRD spec', horizontalalignment='left', verticalalignment='top', color='k')
    ax.axhline(m5.loc[band, 'SRD min'], linestyle=':', color='r')
    ax.text(0, m5.loc[band, 'SRD min'], 'SRD min', horizontalalignment='left', verticalalignment='bottom', color='r')
    ax.axhline(m5.loc[band, 'SRD stretch'], linestyle=':', color='g')
    ax.text(0, m5.loc[band, 'SRD stretch'], 'SRD stretch', horizontalalignment='left', verticalalignment='top', color='g')
    ax.axhline(m5.loc[band, 'b. median'], linestyle='--', color='b')
    ax.text(0, m5.loc[band, 'b. median'], 'baseline 1.7 sim median', horizontalalignment='left', verticalalignment='top', color='b')
    ax.axhline(m5.loc[band, 'b. teff mean'], linestyle='--', color='k')
    ax.text(0, m5.loc[band, 'b. teff mean'], 'baseline 1.7 sim t_eff mean', horizontalalignment='left', verticalalignment='top', color='k')
```

## Examine as differences from baseline averages

```python
m5.apply(lambda c: c - m5['b. teff mean'], axis=0)
```

      SRD spec
      SRD min
      SRD stretch
      overview
      airmass
      throughput
      2exp
      seeing
      sky
      b. median
      b. mean
      b. teff mean

      band

      u
      0.45
      -0.05
      0.55
      0.34
      0.12
      0.41
      0.19
      0.10
      0.02
      -0.06
      -0.09
      0.00

      g
      0.50
      0.10
      0.60
      0.32
      0.15
      0.24
      0.15
      0.07
      -0.05
      -0.06
      -0.10
      0.00

      r
      0.65
      0.25
      0.75
      0.30
      0.16
      0.21
      0.16
      0.08
      -0.03
      -0.05
      -0.09
      0.00

      i
      0.50
      0.10
      0.60
      0.42
      0.28
      0.34
      0.30
      0.22
      0.08
      -0.07
      -0.13
      0.00

      z
      0.46
      0.06
      0.56
      0.49
      0.38
      0.42
      0.39
      0.32
      -0.11
      -0.06
      -0.10
      0.00

      y
      0.04
      -0.36
      0.14
      0.39
      0.24
      0.28
      0.26
      0.19
      -0.01
      -0.05
      -0.08
      0.00

```python
m5.apply(lambda c: c - m5['b. median'], axis=0)
```

      SRD spec
      SRD min
      SRD stretch
      overview
      airmass
      throughput
      2exp
      seeing
      sky
      b. median
      b. mean
      b. teff mean

      band

      u
      0.51
      0.01
      0.61
      0.40
      0.17
      0.47
      0.24
      0.15
      0.07
      0.00
      -0.04
      0.06

      g
      0.56
      0.16
      0.66
      0.37
      0.20
      0.29
      0.21
      0.12
      0.01
      0.00
      -0.04
      0.06

      r
      0.70
      0.30
      0.80
      0.35
      0.21
      0.26
      0.21
      0.13
      0.02
      0.00
      -0.04
      0.05

      i
      0.57
      0.17
      0.67
      0.49
      0.36
      0.41
      0.37
      0.29
      0.15
      0.00
      -0.06
      0.07

      z
      0.52
      0.12
      0.62
      0.55
      0.44
      0.48
      0.46
      0.38
      -0.04
      0.00
      -0.04
      0.06

      y
      0.10
      -0.30
      0.20
      0.44
      0.29
      0.33
      0.31
      0.24
      0.04
      0.00
      -0.03
      0.05

```python
m5.apply(lambda c: c - m5['b. mean'], axis=0)
```

      SRD spec
      SRD min
      SRD stretch
      overview
      airmass
      throughput
      2exp
      seeing
      sky
      b. median
      b. mean
      b. teff mean

      band

      u
      0.55
      0.05
      0.65
      0.44
      0.21
      0.50
      0.28
      0.19
      0.11
      0.04
      0.00
      0.09

      g
      0.60
      0.20
      0.70
      0.41
      0.24
      0.33
      0.25
      0.16
      0.05
      0.04
      0.00
      0.10

      r
      0.74
      0.34
      0.84
      0.39
      0.25
      0.30
      0.25
      0.17
      0.06
      0.04
      0.00
      0.09

      i
      0.63
      0.23
      0.73
      0.55
      0.41
      0.47
      0.43
      0.35
      0.21
      0.06
      0.00
      0.13

      z
      0.56
      0.16
      0.66
      0.59
      0.48
      0.52
      0.50
      0.42
      -0.01
      0.04
      0.00
      0.10

      y
      0.13
      -0.27
      0.23
      0.47
      0.32
      0.36
      0.35
      0.27
      0.07
      0.03
      0.00
      0.08

## Find t_eff stats

```python
def compute_teff_stats(*args, **kwargs):
    teff_stats = compute_teff(*args, **kwargs).groupby('band').describe().loc[bands.index].T
    return teff_stats
```

```python
m5.apply(lambda r: compute_teff_stats(r).loc['mean'])
```

      SRD spec
      SRD min
      SRD stretch
      overview
      airmass
      throughput
      2exp
      seeing
      sky
      b. median
      b. mean
      b. teff mean

      band

      u
      0.43
      1.09
      0.36
      0.53
      0.81
      0.47
      0.71
      0.84
      0.97
      1.11
      1.19
      1.00

      g
      0.40
      0.83
      0.33
      0.56
      0.76
      0.65
      0.76
      0.89
      1.09
      1.11
      1.19
      1.00

      r
      0.30
      0.64
      0.25
      0.57
      0.75
      0.68
      0.74
      0.86
      1.05
      1.10
      1.18
      1.00

      i
      0.40
      0.83
      0.33
      0.46
      0.59
      0.54
      0.57
      0.66
      0.86
      1.14
      1.27
      1.00

      z
      0.43
      0.90
      0.36
      0.41
      0.50
      0.46
      0.48
      0.56
      1.22
      1.12
      1.20
      1.00

      y
      0.92
      1.92
      0.77
      0.49
      0.64
      0.60
      0.62
      0.71
      1.02
      1.10
      1.17
      1.00

```python
m5.apply(lambda r: compute_teff_stats(r).loc['50%'])
```

      SRD spec
      SRD min
      SRD stretch
      overview
      airmass
      throughput
      2exp
      seeing
      sky
      b. median
      b. mean
      b. teff mean

      band

      u
      0.39
      0.98
      0.32
      0.48
      0.72
      0.42
      0.64
      0.75
      0.87
      1.00
      1.07
      0.90

      g
      0.36
      0.75
      0.30
      0.50
      0.69
      0.59
      0.68
      0.80
      0.98
      1.00
      1.07
      0.90

      r
      0.28
      0.58
      0.23
      0.52
      0.68
      0.62
      0.68
      0.79
      0.96
      1.00
      1.07
      0.91

      i
      0.35
      0.73
      0.29
      0.41
      0.52
      0.47
      0.50
      0.58
      0.75
      1.00
      1.11
      0.88

      z
      0.38
      0.80
      0.32
      0.36
      0.45
      0.41
      0.43
      0.50
      1.08
      1.00
      1.07
      0.89

      y
      0.84
      1.75
      0.70
      0.44
      0.58
      0.54
      0.56
      0.65
      0.93
      1.00
      1.06
      0.91

## Progression table

```python
m5
```

      SRD spec
      SRD min
      SRD stretch
      overview
      airmass
      throughput
      2exp
      seeing
      sky
      b. median
      b. mean
      b. teff mean

      band

      u
      23.90
      23.40
      24.00
      23.79
      23.56
      23.85
      23.63
      23.54
      23.46
      23.39
      23.35
      23.45

      g
      25.00
      24.60
      25.10
      24.81
      24.65
      24.73
      24.65
      24.56
      24.45
      24.44
      24.40
      24.50

      r
      24.70
      24.30
      24.80
      24.36
      24.21
      24.26
      24.22
      24.13
      24.03
      24.00
      23.96
      24.05

      i
      24.00
      23.60
      24.10
      23.92
      23.78
      23.83
      23.80
      23.72
      23.58
      23.43
      23.37
      23.50

      z
      23.30
      22.90
      23.40
      23.33
      23.22
      23.26
      23.24
      23.16
      22.74
      22.78
      22.74
      22.84

      y
      22.10
      21.70
      22.20
      22.45
      22.30
      22.34
      22.32
      22.24
      22.04
      22.00
      21.97
      22.06

```python
m5prog = m5[['overview']].copy()

m5prog['throughput'] = overview_table2.reset_index().apply(
        lambda r: m5_flat_sed(r['band'], r['m_sky'], overview_table2['theta_eff'][r['band']], expTime=15, airmass=1, nexp=2), axis=1).values
m5prog['seeing'] = overview_table2.reset_index().apply(
        lambda r: m5_flat_sed(r['band'], r['m_sky'],
                              pd.Series(seeing_model(seeing500, 1.0)['fwhmEff'], index=bands.index)[r['band']],
                              expTime=15, airmass=1, nexp=2), axis=1).values
m5prog['sky'] = overview_table2.reset_index().apply(
        lambda r: m5_flat_sed(r['band'],
                              dark_sky_mags[r['band']].median(),
                              pd.Series(seeing_model(seeing500, 1.0)['fwhmEff'], index=bands.index)[r['band']],
                              expTime=15, airmass=1, nexp=2), axis=1).values
m5prog['X_k'] = overview_table2.reset_index().apply(
        lambda r: m5_flat_sed(r['band'],
                              dark_sky_mags[r['band']].median(),
                              pd.Series(seeing_model(seeing500, 1.0)['fwhmEff'], index=bands.index)[r['band']],
                              expTime=15, airmass=1.2, nexp=2), axis=1).values
m5prog['X_seeing'] = overview_table2.reset_index().apply(
        lambda r: m5_flat_sed(r['band'],
                              dark_sky_mags[r['band']].median(),
                              pd.Series(seeing_model(seeing500, 1.2)['fwhmEff'], index=bands.index)[r['band']],
                              expTime=15, airmass=1.2, nexp=2), axis=1).values
m5prog['X'] = overview_table2.reset_index().apply(
        lambda r: m5_flat_sed(r['band'],
                              sky_x12_mag_stats.loc['75%', r['band']],
                              pd.Series(seeing_model(seeing500, 1.2)['fwhmEff'], index=bands.index)[r['band']],
                              expTime=15, airmass=1.2, nexp=2), axis=1).values
m5prog['moon'] = overview_table2.reset_index().apply(
        lambda r: m5_flat_sed(r['band'],
                              typical_m_sky[r['band']],
                              pd.Series(seeing_model(seeing500, 1.2)['fwhmEff'], index=bands.index)[r['band']],
                              expTime=15, airmass=1.2, nexp=2), axis=1).values
print(m5prog)
```

          overview  throughput  seeing   sky   X_k  X_seeing     X  moon
    band
    u        23.79       23.86   23.76 23.71 23.61     23.49 23.46 23.46
    g        24.81       24.82   24.73 24.67 24.63     24.51 24.45 24.45
    r        24.36       24.36   24.28 24.24 24.22     24.10 24.03 24.03
    i        23.92       23.93   23.86 23.82 23.80     23.68 23.60 23.58
    z        23.33       23.35   23.29 23.21 23.20     23.08 23.00 22.74
    y        22.45       22.47   22.39 22.40 22.37     22.25 22.16 22.04

```python
print(m5.loc[:, ['b. median', 'b. mean']].apply(lambda x: x - m5prog.moon).rename(columns={'b. median': 'sim median - model', 'b. mean': 'sim mean - model'}))
```

          sim median - model  sim mean - model
    band
    u                  -0.07             -0.11
    g                  -0.01             -0.05
    r                  -0.02             -0.06
    i                  -0.15             -0.21
    z                   0.04              0.01
    y                  -0.04             -0.07

```python
m5p_imp = m5prog.T
m5p_imp.index.rename('step', inplace=True)
m5p_imp.reset_index(inplace=True)
fig, axes = plt.subplots(2, 3, figsize=(16, 12))
for band, ax in zip(bands.band, axes.flatten()):
    ax.plot(m5p_imp.index, m5p_imp[band], marker='o', linestyle=' ', color='darkblue')
    ax.plot(m5p_imp.index, m5p_imp[band], alpha=0.2, color='darkblue')
    ax.set_xticks(np.arange(len(m5p_imp['step'])))
    ax.set_xticklabels(m5p_imp['step'], rotation=20)
    ax.set_title(band)
    ax.hlines(m5.loc[band, ['SRD spec', 'SRD min', 'SRD stretch']], xmin=0, xmax=3, linestyle=':', color='k')
    ax.text(0, m5.loc[band, 'SRD spec'], 'SRD spec', horizontalalignment='left', verticalalignment='top', color='k')
    ax.text(0, m5.loc[band, 'SRD min'], 'SRD min', horizontalalignment='left', verticalalignment='bottom', color='r')
    ax.text(0, m5.loc[band, 'SRD stretch'], 'SRD stretch', horizontalalignment='left', verticalalignment='top', color='g')
    ax.axhline(m5.loc[band, 'b. median'], linestyle='--', color='b')
    ax.text(0, m5.loc[band, 'b. median'], 'baseline 1.7 sim median', horizontalalignment='left', verticalalignment='top', color='b')
    ax.axhline(m5.loc[band, 'b. teff mean'], linestyle='--', color='k')
    ax.text(0, m5.loc[band, 'b. teff mean'], 'baseline 1.7 sim t_eff mean', horizontalalignment='left', verticalalignment='top', color='k')
```

## Using median airmass, seeing, sky brightness of baseline visits

```python
m5visits = m5prog[['overview', 'throughput', 'seeing', 'sky']].copy()
m5visits['sim_X'] = overview_table2.reset_index().apply(
        lambda r: m5_flat_sed(r['band'],
                              dark_sky_mags[r['band']].median(),
                              pd.Series(seeing_model(seeing500, 1.0)['fwhmEff'], index=bands.index)[r['band']],
                              expTime=15, airmass=visits.loc[visits['filter']==r['band'],'airmass'].median(), nexp=2), axis=1).values
m5visits['sim_seeing'] = overview_table2.reset_index().apply(
        lambda r: m5_flat_sed(r['band'],
                              dark_sky_mags[r['band']].median(),
                              visits.loc[visits['filter']==r['band'], 'seeingFwhmEff'].median(),
                              expTime=15, airmass=visits.loc[visits['filter']==r['band'], 'airmass'].median(), nexp=2), axis=1).values
m5visits['sim_sky'] = overview_table2.reset_index().apply(
        lambda r: m5_flat_sed(r['band'],
                              visits.loc[visits['filter']==r['band'], 'skyBrightness'].median(),
                              visits.loc[visits['filter']==r['band'], 'seeingFwhmEff'].median(),
                              expTime=15, airmass=visits.loc[visits['filter']==r['band'],'airmass'].median(), nexp=2), axis=1).values
print(m5visits)
```

    step  overview  throughput  seeing   sky  sim_X  sim_seeing  sim_sky
    band
    u        23.79       23.86   23.76 23.71  23.65       23.52    23.51
    g        24.81       24.82   24.73 24.67  24.64       24.51    24.48
    r        24.36       24.36   24.28 24.24  24.23       24.10    24.04
    i        23.92       23.93   23.86 23.82  23.81       23.67    23.54
    z        23.33       23.35   23.29 23.21  23.21       23.07    22.83
    y        22.45       22.47   22.39 22.40  22.38       22.24    22.04

```python
print(m5.loc[:, ['b. median', 'b. mean']].apply(lambda x: x - m5visits.sim_sky).rename(columns={'b. median': 'sim median - model', 'b. mean': 'sim mean - model'}))
```

          sim median - model  sim mean - model
    band
    u                  -0.12             -0.16
    g                  -0.03             -0.07
    r                  -0.04             -0.08
    i                  -0.12             -0.17
    z                  -0.05             -0.08
    y                  -0.04             -0.07

```python
m5p_imp = m5visits.T
m5p_imp.index.rename('step', inplace=True)
m5p_imp.reset_index(inplace=True)
fig, axes = plt.subplots(2, 3, figsize=(16, 12))
for band, ax in zip(bands.band, axes.flatten()):
    ax.plot(m5p_imp.index, m5p_imp[band], marker='o', linestyle=' ', color='darkblue')
    ax.plot(m5p_imp.index, m5p_imp[band], alpha=0.2, color='darkblue')
    ax.set_xticks(np.arange(len(m5p_imp['step'])))
    ax.set_xticklabels(m5p_imp['step'], rotation=20)
    ax.set_title(band)
    ax.hlines(m5.loc[band, ['SRD spec', 'SRD min', 'SRD stretch']], xmin=0, xmax=3, linestyle=':', color='k')
    ax.text(0, m5.loc[band, 'SRD spec'], 'SRD spec', horizontalalignment='left', verticalalignment='top', color='k')
    ax.text(0, m5.loc[band, 'SRD min'], 'SRD min', horizontalalignment='left', verticalalignment='bottom', color='r')
    ax.text(0, m5.loc[band, 'SRD stretch'], 'SRD stretch', horizontalalignment='left', verticalalignment='top', color='g')
    ax.axhline(m5.loc[band, 'b. median'], linestyle='--', color='b')
    ax.text(0, m5.loc[band, 'b. median'], 'baseline 1.7 sim median', horizontalalignment='left', verticalalignment='top', color='b')
    ax.axhline(m5.loc[band, 'b. teff mean'], linestyle='--', color='k')
    ax.text(0, m5.loc[band, 'b. teff mean'], 'baseline 1.7 sim t_eff mean', horizontalalignment='left', verticalalignment='top', color='k')
```

## Sky brightness and moon

```python
fig, axes = plt.subplots(3, 2, figsize=(13, 8))
for band, ax in zip(bands.band, axes.flatten()):
    visits.query(f"band == '{band}'").hist('skyBrightness', bins=100, ax=ax)
    text_trans = mpl.transforms.blended_transform_factory(ax.transData, ax.transAxes)

    ax.axvline(sky_x12_mag_stats.loc['mean', band], color='darkblue')
    ax.text(sky_x12_mag_stats.loc['mean', band], 1, 'mean at X=1.2', transform=text_trans, horizontalalignment='right', verticalalignment='top', rotation=90, color='darkblue')

    ax.axvline(sky_x12_mag_stats.loc['min', band], color='black', linestyle=":")
    ax.text(sky_x12_mag_stats.loc['min', band], 1, 'min at X=1.2', transform=text_trans, horizontalalignment='right', verticalalignment='top', rotation=90, color='black')
    ax.axvline(sky_x12_mag_stats.loc['max', band], color='black', linestyle=":")

    ax.axvline(sky_x12_mag_stats.loc['25%', band], color='black', linestyle='--')
    ax.text(sky_x12_mag_stats.loc['25%', band], 1, '25% at X=1.2', transform=text_trans, horizontalalignment='right', verticalalignment='top', rotation=90, color='black')
    ax.axvline(sky_x12_mag_stats.loc['75%', band], color='black', linestyle='--')

    ax.axvline(sky_x12_mag_stats.loc['50%', band], color='black')
    ax.text(sky_x12_mag_stats.loc['50%', band], 1, 'median at X=1.2', transform=text_trans, horizontalalignment='right', verticalalignment='top', rotation=90, color='black')

    ax.set_title(band)
```

```python
sky_mags_x12.describe()
```

      u
      g
      r
      i
      z
      y
      mjd

      count
      146227.00
      146227.00
      146227.00
      146227.00
      146227.00
      146227.00
      148164.00

      mean
      21.32
      20.68
      20.05
      19.61
      18.92
      18.28
      61691.99

      std
      1.57
      1.62
      1.20
      0.84
      0.48
      0.26
      1054.35

      min
      17.75
      16.93
      16.85
      17.01
      17.08
      17.07
      59854.00

      25%
      19.85
      19.13
      19.04
      19.03
      18.70
      18.19
      60788.37

      50%
      22.45
      21.83
      20.87
      20.16
      19.22
      18.43
      61680.17

      75%
      22.56
      21.96
      20.95
      20.21
      19.25
      18.44
      62615.11

      max
      22.61
      22.07
      21.03
      20.26
      19.28
      18.47
      63506.39

## Checking parameters from sims_utils

```python
sims_utils_params = pd.DataFrame({'C_m': m5_flat_sed.Cm,
                                  'Delta_C_m_infinity': m5_flat_sed.dCm_infinity})
param_compare = pd.concat([overview_table2[sims_utils_params.columns], sims_utils_params], keys=['overview table 2', 'sims_utils'], axis=1).reorder_levels((1,0), axis=1)
param_compare = param_compare.reindex(sorted(param_compare.columns), axis=1)
print(param_compare)
```

                   C_m            Delta_C_m_infinity
      overview table 2 sims_utils   overview table 2 sims_utils
    u            23.09      23.39               0.62       0.37
    g            24.42      24.51               0.18       0.10
    r            24.44      24.49               0.10       0.05
    i            24.32      24.37               0.07       0.04
    z            24.16      24.20               0.05       0.02
    y            23.73      23.77               0.04       0.02

## Coadd-depth based $t_{\mbox{eff}}$

```python
coadd_depth = pd.DataFrame({'mag': [26.1, 27.4, 27.5, 26.8, 26.1, 24.9],
                            'visits': [56, 80, 184, 184, 160, 160]}, index=bands.band).reindex(visits.index)
coadd_depth['m0'] = coadd_depth.mag - 1.25*np.log10(coadd_depth.visits)
coadd_depth['prog'] = m5prog.moon
coadd_depth['round'] = np.round(m5prog.sky*2)/2
coadd_depth['m0_visit_teff'] = (10**(0.8*(visits.fiveSigmaDepth - coadd_depth.m0))).groupby('band').mean()
coadd_depth['prog_visit_teff'] = (10**(0.8*(visits.fiveSigmaDepth - coadd_depth.prog))).groupby('band').mean()
coadd_depth['round_visit_teff'] = (10**(0.8*(visits.fiveSigmaDepth - coadd_depth['round']))).groupby('band').mean()
coadd_depth.groupby('band').first().loc[bands.band]
```

      mag
      visits
      m0
      prog
      round
      m0_visit_teff
      prog_visit_teff
      round_visit_teff

      band

      u
      26.10
      56
      23.91
      23.46
      23.50
      0.42
      0.97
      0.90

      g
      27.40
      80
      25.02
      24.45
      24.50
      0.38
      1.09
      1.00

      r
      27.50
      184
      24.67
      24.03
      24.00
      0.32
      1.05
      1.11

      i
      26.80
      184
      23.97
      23.58
      24.00
      0.42
      0.86
      0.40

      z
      26.10
      160
      23.34
      22.74
      23.00
      0.40
      1.22
      0.75

      y
      24.90
      160
      22.14
      22.04
      22.50
      0.85
      1.02
      0.44

## Best possible as reference for $t_{\mbox{eff}}$

```python
overview_table2.reset_index().apply(
        lambda r: m5_flat_sed(r['band'],
                              dark_sky_mags[r['band']].max(),
                              pd.Series(seeing_model(0.0, 1.0)['fwhmEff'], index=bands.index)[r['band']],
                              expTime=15, airmass=1, nexp=2), axis=1)
```

    0   24.56
    1   25.51
    2   25.01
    3   24.52
    4   23.87
    5   23.02
    dtype: float64

```python
best_m5 = pd.DataFrame({
    'm5': overview_table2.reset_index().apply(lambda r: m5_flat_sed(r['band'],
                                                                    dark_sky_mags[r['band']].max(),
                                                                    pd.Series(seeing_model(0.0, 1.0)['fwhmEff'], index=bands.index)[r['band']],
                                                                    expTime=15, airmass=1, nexp=2), axis=1).values},
    index=bands.band)
best_m5['round_best'] = np.round(best_m5.m5*2)/2
best_m5['m5 mean teff'] = 30*(10**(0.8*(visits.fiveSigmaDepth - best_m5.reindex(visits.index).m5))).groupby('band').mean()
best_m5['round mean teff'] = 30*(10**(0.8*(visits.fiveSigmaDepth - best_m5.reindex(visits.index).round_best))).groupby('band').mean()
best_m5
```

      m5
      round_best
      m5 mean teff
      round mean teff

      band

      u
      24.56
      24.50
      3.81
      4.30

      g
      25.51
      25.50
      4.63
      4.74

      r
      25.01
      25.00
      5.19
      5.26

      i
      24.52
      24.50
      4.56
      4.74

      z
      23.87
      24.00
      4.55
      3.55

      y
      23.02
      23.00
      5.06
      5.26

### Distributions of t_eff in units of ideal visits, unrounded reference points

```python
teff = (10**(0.8*(visits.fiveSigmaDepth - best_m5.reindex(visits.index).m5)))
fig, axes = plt.subplots(3,2, sharex=True, sharey=True)
for band, ax in zip(bands.band, axes.flatten()):
    teff[band].hist(bins=np.arange(20)/30, ax=ax, density=True)
    ax.text(0.97, 0.95, band, horizontalalignment='right', verticalalignment='top', transform=ax.transAxes)
    ax.set_yticklabels([])
plt.tight_layout()
```

### Distributions in units of ideal seconds, unrounded reference points

```python
teff = 30*(10**(0.8*(visits.fiveSigmaDepth - best_m5.reindex(visits.index).m5)))
fig, axes = plt.subplots(3,2, sharex=True, sharey=True)
for band, ax in zip(bands.band, axes.flatten()):
    teff[band].hist(bins=np.arange(20), ax=ax, density=True)
    ax.text(0.97, 0.95, band, horizontalalignment='right', verticalalignment='top', transform=ax.transAxes)
    ax.set_yticklabels([])
plt.tight_layout()
```

### Distributions in unites of ideal seconds, rounded reference points

```python
round_teff = 30*(10**(0.8*(visits.fiveSigmaDepth - best_m5.reindex(visits.index).round_best)))
```

```python
fig, axes = plt.subplots(3,2, sharex=True, sharey=True)
for band, ax in zip(bands.band, axes.flatten()):
    round_teff[band].hist(bins=np.arange(20), ax=ax, density=True)
    ax.text(0.97, 0.95, band, horizontalalignment='right', verticalalignment='top', transform=ax.transAxes)
    ax.set_yticklabels([])
plt.tight_layout()
```

```python

```

```python

```


# Start-to-Finish Example: Numerical Solution of the Scalar Wave Equation, in Curvilinear Coordinates

## This module solves the scalar wave equation for a plane wave in *spherical coordinates*

**This module has been validated to converge at the expected order to the exact solution (see plot at bottom).**

### NRPy+ Source Code for this module: [ScalarWaveCurvilinear/ScalarWaveCurvilinear_RHSs.py](../edit/ScalarWaveCurvlinear/ScalarWaveCurvilinear_RHSs.py); [ScalarWave/InitialData_PlaneWave.py](../edit/ScalarWave/InitialData_PlaneWave.py)

As outlined in the [previous NRPy+ tutorial module](Tutorial-ScalarWaveCurvilinear.ipynb), we first use NRPy+ to generate initial data for the scalar wave equation, and then we use it to generate the RHS expressions for [Method of Lines](https://reference.wolfram.com/language/tutorial/NDSolveMethodOfLines.html) time integration based on the [explicit Runge-Kutta fourth-order scheme](https://en.wikipedia.org/wiki/Runge%E2%80%93Kutta_methods) (RK4).

The entire algorithm is outlined below, with NRPy+-based components highlighted in green.

1. Allocate memory for gridfunctions, including temporary storage for the RK4 time integration.
1. Set gridfunction values to initial data.
1. Evolve the system forward in time using RK4 time integration. At each RK4 substep, do the following:
    1. Evaluate scalar wave RHS expressions.
    1. Apply boundary conditions.
1. At the end of each iteration in time, output the relative error between numerical and exact solutions.

## Step 1 of 2: Call on NRPy+ to output needed C code for initial data and scalar wave RHSs

We choose simple plane wave initial data, which is documented in the [Cartesian scalar wave module](Tutorial-ScalarWave.ipynb). Specifically, we implement monochromatic (single-wavelength) wave traveling in the $\hat{k}$ direction with speed $c$
$$u(\vec{x},t) = f(\hat{k}\cdot\vec{x} - c t),$$
where $\hat{k}$ is a unit vector.

The scalar wave RHSs in curvilinear coordinates (documented [in the previous module](Tutorial-ScalarWaveCurvilinear.ipynb)) are simply the right-hand sides of the scalar wave equation written in curvilinear coordinates
\begin{align}
\partial_t u &= v \\
\partial_t v &= c^2 \left(\hat{g}^{ij} \partial_{i} \partial_{j} u - \hat{\Gamma}^i \partial_i u\right),
\end{align}
where $\hat{g}^{ij}$ is the inverse reference 3-metric (i.e., the metric corresponding to the underlying coordinate system we choose$-$spherical coordinates in our example below), and $\hat{\Gamma}^i$ is the contracted Christoffel symbol $\hat{\Gamma}^\tau = \hat{g}^{\mu\nu} \hat{\Gamma}^\tau_{\mu\nu}$.

Below we generate
+ the initial data by calling InitialData_PlaneWave() inside the NRPy+ [ScalarWave/InitialData_PlaneWave.py](../edit/ScalarWave/InitialData_PlaneWave.py) module (documented in [this NRPy+ Jupyter notebook](Tutorial-ScalarWave.ipynb)), and
+ the RHS expressions by calling ScalarWaveCurvilinear_RHSs() inside the NRPy+ [ScalarWaveCurvilinear/ScalarWaveCurvilinear_RHSs.py](../edit/ScalarWaveCurvilinear/ScalarWaveCurvilinear_RHSs.py) module (documented in [this NRPy+ Jupyter notebook](Tutorial-ScalarWaveCurvilinear.ipynb)).

```python
# Step P1: Import needed NRPy+ core modules:
import NRPy_param_funcs as par
import indexedexp as ixp
import grid as gri
import reference_metric as rfm
import finite_difference as fin
import loop as lp
from outputC import *

# Step P2: Set the coordinate system for the numerical grid
par.set_parval_from_str("reference_metric::CoordSystem","Spherical")

# Step 1: Import the ScalarWave.InitialData module.
#         This command only declares ScalarWave initial data
#         parameters and the InitialData_PlaneWave() function.
import ScalarWave.InitialData_PlaneWave as swid

# Step 2: Import ScalarWave_RHSs module.
#         This command only declares ScalarWave RHS parameters
#         and the ScalarWave_RHSs function (called later)
import ScalarWaveCurvilinear.ScalarWaveCurvilinear_RHSs as swrhs

# Step 3: Set the spatial dimension parameter
#         to *FOUR* this time, and then read
#         the parameter as DIM.
par.set_parval_from_str("grid::DIM",3)
DIM = par.parval_from_str("grid::DIM")

# Step 4: Set the finite differencing order to 6.
par.set_parval_from_str("finite_difference::FD_CENTDERIVS_ORDER",6)

# Step 5: Call the InitialData_PlaneWave() function to set up
#         monochromatic (single frequency/wavelength) scalar
#         wave initial data.
swid.InitialData_PlaneWave()

# Step 6: Generate SymPy symbolic expressions for
#         uu_rhs and vv_rhs; the ScalarWave RHSs.
#         This function also declares the uu and vv
#         gridfunctions, which need to be declared
#         to output even the initial data to C file.
swrhs.ScalarWaveCurvilinear_RHSs()

# Step 7: Generate C code for the initial data,
#         output to a file named "SENR/ScalarWave_InitialData.h".
IDstring = fin.FD_outputC("returnstring",[lhrh(lhs=gri.gfaccess("in_gfs","uu"),rhs=swid.uu_ID),
                                          lhrh(lhs=gri.gfaccess("in_gfs","vv"),rhs=swid.vv_ID)])
with open("ScalarWaveCurvilinear/ScalarWaveCartesian_ExactSolution.h", "w") as file:
    file.write(IDstring)

# Step 8: Generate C code for scalarwave RHSs,
#         output to a file named "SENR/ScalarWave_RHSs.h".
RHSstring = fin.FD_outputC("returnstring",[lhrh(lhs=gri.gfaccess("rhs_gfs","uu"),rhs=swrhs.uu_rhs),
                                           lhrh(lhs=gri.gfaccess("rhs_gfs","vv"),rhs=swrhs.vv_rhs)])
with open("ScalarWaveCurvilinear/ScalarWaveCurvilinear_RHSs.h", "w") as file:
    file.write(lp.loop(["i2","i1","i0"],["NGHOSTS","NGHOSTS","NGHOSTS"],
                       ["NGHOSTS+Nxx[2]","NGHOSTS+Nxx[1]","NGHOSTS+Nxx[0]"],
                       ["1","1","1"],["const REAL invdx0 = 1.0/dxx[0];\n"+
                                      "const REAL invdx1 = 1.0/dxx[1];\n"+
                                      "const REAL invdx2 = 1.0/dxx[2];\n"+
                                      "#pragma omp parallel for",
                                      "    const REAL xx2 = xx[2][i2];",
                                      "        const REAL xx1 = xx[1][i1];"],"",
                                     "const REAL xx0 = xx[0][i0];\n"+RHSstring))
```

## Step 1 of 2, continued: Output quantities related to reference metric

There are several subtleties when generalizing the [start-to-finish scalar wave tutorial in Cartesian coordinates](Tutorial-Start_to_Finish-ScalarWave.ipynb) to curvilinear coordinates.

Consider for example *ordinary* (as opposed to, e.g., logarithmic-radial) spherical coordinates. In these coordinates, we choose a *uniform* grid in $(x_0,x_1,x_2)=(r,\theta,\phi)$. That is to say, we choose *constant* spacing $\Delta r$, $\Delta \theta$, and $\Delta \phi$ between grid points in $r$, $\theta$, and $\phi$, respectively.

+ Unlike Cartesian coordinates, in which our computational domain with coordinates $(x_0,x_1,x_2)=(x,y,z)$ may extend over arbitrary coordinate ranges $x_i \in [x_{i, \rm min},x_{i, \rm max}]$, our spherical coordinate domains will satisfy

    + $x_0 = r \in [0,{\rm RMAX}]$,
    + $x_1 = \theta \in [0,\pi]$, and
    + $x_2 = \phi \in [-\pi,\pi]$. (Notice how we do not choose $x_2= \phi \in [0,2\pi]$ so that our conversion from Cartesian to spherical coordinates is compatible with the output range from the ${\rm atan2}(y,x)$ function: $\phi={\rm atan2}(y,x)\in[-\pi,\pi]$.)

+ Further, most non-Cartesian, orthogonal coordinate systems (like spherical coordinates) possess *coordinate singularities*. Coordinate singularities in spherical coordinates lie along $\theta=0$ and $\theta=\pi$; these are points where the coordinate system focuses to a single point. For example, the coordinate singularity at the North Pole is the reason why all directions are south there. Critically, these singularities manifest as points where the reference metric or its inverse crosses through zero or diverges to $\infty$. As we derived in a [previous module](Tutorial-ScalarWaveCurvilinear.ipynb), the Laplacian in spherical polar coordinates takes the form
$$
\nabla^2 u = \partial_r^2 u + \frac{1}{r^2} \partial_\theta^2 u + \frac{1}{r^2 \sin^2 \theta} \partial_\phi^2 u +  \frac{2}{r} \partial_r u + \frac{\cos\theta}{r^2 \sin\theta} \partial_\theta u,
$$
which diverges at $r=0$ and $\sin\theta=0-$precisesly at the $\theta=0$ and $\theta=\pi$ coordinate singularity. To avoid this divergence, we simply choose that our numerical grids be *cell-centered*. For example, if we choose a numerical grid with 3 outer ghost zone points (needed due to 6th-order-accurate centered finite differencing), and we want the grid interior to be sampled with ${\rm Nr}\times{\rm Ntheta}\times{\rm Nphi}$ grid points, then the cell-centered grid points can be indexed via three *even* integers $\left(\rm{i0}\in[0,{\rm Nr}+6),\rm{i1}\in[0,{\rm Ntheta}+6),\rm{i2}\in[0,{\rm Ntheta}+6)\right)$:
    + $r_{\rm i0}      = r_{\rm min}      + \left({\rm i0} + \frac{1}{2}\right)\Delta r$, where $r_{\rm min}=\left(-3+\frac{1}{2}\right)\Delta r$;
    + $\theta_{\rm i1} = \theta_{\rm min} + \left({\rm i1} + \frac{1}{2}\right)\Delta \theta$, where $\theta_{\rm min}= \left(-3+\frac{1}{2}\right)\Delta \theta$; and
    + $\phi_{\rm i2}   = \phi_{\rm min}   + \left({\rm i2} + \frac{1}{2}\right)\Delta \phi$, where $\phi_{\rm min}=-\pi+ \left(-3+\frac{1}{2}\right)\Delta \phi$.

+ Unlike Cartesian coordinates, the boundaries of our grid $x_i \in [x_{i, \rm min},x_{i, \rm max}]$ in spherical coordinates are not all outer boundaries. This presents some additional challenges, as finite difference numerical grids of $N$th order accuracy generally possess $N/2$ *ghost zone points* in regions $x_i < x_{i,\rm min}$ and $x_i > x_{i, \rm max}$. While in Cartesian coordinates, these ghost zone points map to regions outside the grid domain $x_i \in [x_{i, \rm min},x_{i, \rm max}]$, in spherical coordinates, most ghost zone points map to regions *inside* the grid domain. For example, for some $\tilde{r}\in [0,{\rm RMAX}]$ and $\tilde{\theta}\in[0,\pi]$, the ghost zone point $(\tilde{r},\tilde{\theta},2\pi+\Delta \phi/2)$ would map to the interior point $(\tilde{r},\tilde{\theta},\Delta \phi/2)$ because the $\phi$ coordinate is periodic. Thus when given a ghost zone point in some non-Cartesian coordinate system, we are faced with the problem of addressing the following two questions:
    1. Does a given ghost point map to an interior point, or is it an outer boundary point (i.e., a point exterior to the domain)?
    1. If the ghost zone point maps to an interior point, to which interior point does it map?

We handle ghost zone points outside the domain boundary in generic coordinate systems by applying the following, simple process at each ghost zone point:
1. Convert the coordinate $(x_0,x_1,x_2)$ for the ghost zone point to Cartesian coordinates $\left(x(x_0,x_1,x_2),y(x_0,x_1,x_2),z(x_0,x_1,x_2)\right)$. For example, if we choose ordinary spherical coordinates $(x_0,x_1,x_2)=(r,\theta,\phi)$, then
    + $x(r,\theta,\phi) = r \sin(\theta) \cos(\phi) = x_0 \sin(x_1) \cos(x_2)$
    + $y(r,\theta,\phi) = r \sin(\theta) \sin(\phi) = x_0 \sin(x_1) \sin(x_2)$
    + $z(r,\theta,\phi) = r \cos(\theta)            = x_0 \cos(x_1)$
1. Once we have $(x,y,z)$, we then find the corresponding value $(x_0,x_1,x_2)_{\rm in/OB}=(r,\theta,\phi)_{\rm in/OB}$ *in the grid interior or outer boundary*, via the simple inverse formula:
    + $r_{\rm in/OB}      = x_{0, \rm in/OB} = \sqrt{x^2+y^2+z^2} \in [0,\infty)$
    + $\theta_{\rm in/OB} = x_{1, \rm in/OB} = {\rm acos}\left(\frac{z}{\sqrt{x^2+y^2+z^2}}\right) \in [0,\pi]$
    + $\phi_{\rm in/OB}   = x_{2, \rm in/OB} = {\rm atan2}(y,x) \in [-\pi,\pi]$ [Wikipedia article on atan2()](https://en.wikipedia.org/w/index.php?title=Atan2&oldid=859313982)

1. If $(x_0,x_1,x_2)_{\rm in/OB}$ is the same as the original $(x_0,x_1,x_2)$, then we know $(x_0,x_1,x_2)$ is an outer boundary point (in spherical coordinates, at $r>{\rm RMAX}$), and we store $\left(\rm{i0},\rm{i1},\rm{i2}\right)_{\rm in/OB} = (-1,-1,-1)$. Otherwise, we know that $(x_0,x_1,x_2)$ maps to some interior point at index $(\rm{i0},\rm{i1},\rm{i2})$, which we store:
    + $\rm{i0}_{\rm in/OB}=\frac{r_{\rm in/OB}      -      r_{\rm min}}{\Delta r}      - \frac{1}{2}$
    + $\rm{i1}_{\rm in/OB}=\frac{\theta_{\rm in/OB} - \theta_{\rm min}}{\Delta \theta} - \frac{1}{2}$
    + $\rm{i2}_{\rm in/OB}=\frac{\phi_{\rm in/OB}   -   \phi_{\rm min}}{\Delta \phi}   - \frac{1}{2}$

1. When updating a ghost zone point $\left(\rm{i0},\rm{i1},\rm{i2}\right)$ in the domain exterior, if the corresponding $\left(\rm{i0},\rm{i1},\rm{i2}\right)_{\rm in/OB}$ was set to $(-1,-1,-1)$, then we apply outer boundary conditions. Otherwise, we simply copy the data from the interior point at $\left(\rm{i0},\rm{i1},\rm{i2}\right)_{\rm in/OB}$ to $\left(\rm{i0},\rm{i1},\rm{i2}\right)$.

**Exercise to student: Given the prescripion above, why do the integers $\left(\rm{i0},\rm{i1},\rm{i2}\right)$ need to be even?**

Next we generate the C code needed for applying boundary conditions in generic coordinate systems (and, in the case of "xxminmax.h", to set up the numerical grid coordinates as well):
1. $(x_{0},x_{1},x_{2})_{\rm min}$, $(x_{0},x_{1},x_{2})_{\rm max}$, (ScalarWaveCurvilinear/xxminmax.h)
1. $\left(x(x_0,x_1,x_2),y(x_0,x_1,x_2),z(x_0,x_1,x_2)\right)$, (ScalarWaveCurvilinear/xxCart.h)
1. $\left(x_0(x,y,z),x_1(x,y,z),x_2(x,y,z)\right)$, (ScalarWaveCurvilinear/Cart_to_xx.h):

```python
# Generic coordinate NRPy+ file output, Part 1: output the coordinate bounds xxmin[] and xxmax[]:
with open("ScalarWaveCurvilinear/xxminmax.h", "w") as file:
    file.write("const REAL xxmin[3] = {"+str(rfm.xxmin[0])+","+str(rfm.xxmin[1])+","+str(rfm.xxmin[2])+"};\n")
    file.write("const REAL xxmax[3] = {"+str(rfm.xxmax[0])+","+str(rfm.xxmax[1])+","+str(rfm.xxmax[2])+"};\n")
# Generic coordinate NRPy+ file output, Part 2: output the conversion from (x0,x1,x2) to Cartesian (x,y,z)
outputC([rfm.xxCart[0],rfm.xxCart[1],rfm.xxCart[2]],["xCart[0]","xCart[1]","xCart[2]"],
        "ScalarWaveCurvilinear/xxCart.h")
# Generic coordinate NRPy+ file output, Part 3: output the conversion from Cartesian (x,y,z) to interior/OB (x0,x1,x2)
outputC([rfm.Cart_to_xx[0],rfm.Cart_to_xx[1],rfm.Cart_to_xx[2]],
        ["Cart_to_xx0_inbounds","Cart_to_xx1_inbounds","Cart_to_xx2_inbounds"],
        "ScalarWaveCurvilinear/Cart_to_xx.h")
```

    Wrote to file "ScalarWaveCurvilinear/xxCart.h"
    Wrote to file "ScalarWaveCurvilinear/Cart_to_xx.h"

## Step 2 of 2: Writing the necessary C code infrastructure to solve the scalar wave equation in curvilinear, singular coordinates

### Step 2, Part 1: Write needed C code for implementing boundary condition algorithm above

```python
%%writefile ScalarWaveCurvilinear/curvilinear_boundary_conditions.h

typedef struct ghostzone_map {
  short i0,i1,i2;
} gz_map;

void set_up_bc_gz_map(const int Nxx_plus_2NGHOSTS[3], REAL *xx[3], const REAL dxx[3], const REAL xxmin[3], const REAL xxmax[3], gz_map *bc_gz_map) {
  LOOP_REGION(0,Nxx_plus_2NGHOSTS[0],0,Nxx_plus_2NGHOSTS[1],0,Nxx_plus_2NGHOSTS[2]) {
    REAL xCart[3];
    xxCart(xx, i0,i1,i2, xCart);
    REAL Cartx = xCart[0];
    REAL Carty = xCart[1];
    REAL Cartz = xCart[2];

    REAL Cart_to_xx0_inbounds,Cart_to_xx1_inbounds,Cart_to_xx2_inbounds;
#include "Cart_to_xx.h"
    int i0_inbounds = (int)( (Cart_to_xx0_inbounds - xxmin[0] - (1.0/2.0)*dxx[0] + ((REAL)NGHOSTS)*dxx[0])/dxx[0] + 0.5 );
    int i1_inbounds = (int)( (Cart_to_xx1_inbounds - xxmin[1] - (1.0/2.0)*dxx[1] + ((REAL)NGHOSTS)*dxx[1])/dxx[1] + 0.5 );
    int i2_inbounds = (int)( (Cart_to_xx2_inbounds - xxmin[2] - (1.0/2.0)*dxx[2] + ((REAL)NGHOSTS)*dxx[2])/dxx[2] + 0.5 );

    REAL xCart_orig[3]; for(int ii=0;ii<3;ii++) xCart_orig[ii] = xCart[ii];
    xxCart(xx, i0_inbounds,i1_inbounds,i2_inbounds, xCart);

#define EPS_ABS 1e-8
    if(fabs( (double)(xCart_orig[0] - xCart[0]) ) > EPS_ABS ||
       fabs( (double)(xCart_orig[1] - xCart[1]) ) > EPS_ABS ||
       fabs( (double)(xCart_orig[2] - xCart[2]) ) > EPS_ABS) {
      printf("Error. Cartesian disagreement: ( %.15e %.15e %.15e ) != ( %.15e %.15e %.15e )\n",
             (double)xCart_orig[0],(double)xCart_orig[1],(double)xCart_orig[2],
             (double)xCart[0],(double)xCart[1],(double)xCart[2]);
      exit(1);
    }

    if(i0_inbounds-i0 == 0 && i1_inbounds-i1 == 0 && i2_inbounds-i2 == 0) {
      bc_gz_map[IDX3(i0,i1,i2)].i0=-1;
      bc_gz_map[IDX3(i0,i1,i2)].i1=-1;
      bc_gz_map[IDX3(i0,i1,i2)].i2=-1;
    } else {
      bc_gz_map[IDX3(i0,i1,i2)].i0=i0_inbounds;
      bc_gz_map[IDX3(i0,i1,i2)].i1=i1_inbounds;
      bc_gz_map[IDX3(i0,i1,i2)].i2=i2_inbounds;
    }
  }
}

// Part P6: Declare boundary condition OB_UPDATE macro,
//          which updates a single face of the 3D grid cube
//          using quadratic polynomial extrapolation.
const int MAXFACE = -1;
const int NUL     = +0;
const int MINFACE = +1;

#define OB_UPDATE(inner,which_gf, bc_gz_map, i0min,i0max, i1min,i1max, i2min,i2max, FACEX0,FACEX1,FACEX2) \
  LOOP_REGION(i0min,i0max, i1min,i1max, i2min,i2max) {                  \
    const int idx3 = IDX3(i0,i1,i2);                                    \
    if(bc_gz_map[idx3].i0 == -1 && inner==0) {                          \
      gfs[IDX4(which_gf,i0,i1,i2)] =                                    \
        +3.0*gfs[IDX4(which_gf,i0+1*FACEX0,i1+1*FACEX1,i2+1*FACEX2)]    \
        -3.0*gfs[IDX4(which_gf,i0+2*FACEX0,i1+2*FACEX1,i2+2*FACEX2)]    \
        +1.0*gfs[IDX4(which_gf,i0+3*FACEX0,i1+3*FACEX1,i2+3*FACEX2)];   \
    } else if(bc_gz_map[idx3].i0 != -1 && inner==1) {                   \
      gfs[IDX4(which_gf,i0,i1,i2)] =                                    \
        gfs[IDX4(which_gf,                                              \
                 bc_gz_map[idx3].i0,                                    \
                 bc_gz_map[idx3].i1,                                    \
                 bc_gz_map[idx3].i2)];                                  \
    }                                                                   \
  }

// Part P7: Boundary condition driver routine: Apply BCs to all six
//          boundary faces of the cube, filling in the innermost
//          ghost zone first, and moving outward.
void apply_bcs(const int Nxx[3],const int Nxx_plus_2NGHOSTS[3],gz_map *bc_gz_map,REAL *gfs) {
#pragma omp parallel for
  for(int which_gf=0;which_gf<NUM_GFS;which_gf++) {
    int imin[3] = { NGHOSTS, NGHOSTS, NGHOSTS };
    int imax[3] = { Nxx_plus_2NGHOSTS[0]-NGHOSTS, Nxx_plus_2NGHOSTS[1]-NGHOSTS, Nxx_plus_2NGHOSTS[2]-NGHOSTS };
    for(int which_gz = 0; which_gz < NGHOSTS; which_gz++) {
      for(int inner=0;inner<2;inner++) {
        // After updating each face, adjust imin[] and imax[]
        //   to reflect the newly-updated face extents.
        OB_UPDATE(inner,which_gf, bc_gz_map, imin[0]-1,imin[0], imin[1],imax[1], imin[2],imax[2], MINFACE,NUL,NUL); imin[0]--;
        OB_UPDATE(inner,which_gf, bc_gz_map, imax[0],imax[0]+1, imin[1],imax[1], imin[2],imax[2], MAXFACE,NUL,NUL); imax[0]++;

        OB_UPDATE(inner,which_gf, bc_gz_map, imin[0],imax[0], imin[1]-1,imin[1], imin[2],imax[2], NUL,MINFACE,NUL); imin[1]--;
        OB_UPDATE(inner,which_gf, bc_gz_map, imin[0],imax[0], imax[1],imax[1]+1, imin[2],imax[2], NUL,MAXFACE,NUL); imax[1]++;

        OB_UPDATE(inner,which_gf, bc_gz_map, imin[0],imax[0], imin[1],imax[1], imin[2]-1,imin[2], NUL,NUL,MINFACE); imin[2]--;
        OB_UPDATE(inner,which_gf, bc_gz_map, imin[0],imax[0], imin[1],imax[1], imax[2],imax[2]+1, NUL,NUL,MAXFACE); imax[2]++;
        if(inner==0) { for(int ii=0;ii<3;ii++) {imin[ii]++; imax[ii]--;} }
      }
    }
  }
}
```

    Overwriting ScalarWaveCurvilinear/curvilinear_boundary_conditions.h

### Step 2, Part 2: Output needed C code for finding the minimum proper distance between grid points, needed for [CFL](https://en.wikipedia.org/w/index.php?title=Courant%E2%80%93Friedrichs%E2%80%93Lewy_condition&oldid=806430673)-limited timestep

In order for our explicit-timestepping numerical solution to the scalar wave equation to be stable, it must satisfy the [CFL](https://en.wikipedia.org/w/index.php?title=Courant%E2%80%93Friedrichs%E2%80%93Lewy_condition&oldid=806430673) condition:
$$
\Delta t \le \frac{\min(ds_i)}{c},
$$
where $c$ is the wavespeed, and
$$ds_i = h_i \Delta x^i$$
is the proper distance between neighboring gridpoints in the $i$th direction (in 3D, there are 3 directions), $h_i$ is the $i$th reference metric scale factor, and $\Delta x^i$ is the uniform grid spacing in the $i$th direction:

```python
# Generic coordinate NRPy+ file output, Part 4: output the proper distance between gridpoints in given coordinate system
dxx     = ixp.declarerank1("dxx",DIM=3)
ds_dirn = rfm.ds_dirn(dxx)
outputC([ds_dirn[0],ds_dirn[1],ds_dirn[2]],["ds_dirn0","ds_dirn1","ds_dirn2"],"ScalarWaveCurvilinear/ds_dirn.h")
```

    Wrote to file "ScalarWaveCurvilinear/ds_dirn.h"

### Step 2, Part 3: Output the main() C code function and remaining code needed to solve the scalar wave equation in curvilinear coordinates via Method of Lines+RK4:

Just as in [the start-to-finish, solving the scalar wave equation in Cartesian coordinates module](Tutorial-Start_to_Finish-ScalarWave.ipynb), we will implement the scalar wave equation via the Method of Lines. As discussed above, the critical differences between this code and the Cartesian version are as follows:
1. The CFL-constrained timestep depends on the proper distance between neighboring gridpoints
1. The boundary conditions must account for the fact that ghost zone points lying in the domain exterior can map either to the interior of the domain, or lie on the outer boundary. In the former case, we simply copy the data from the interior. In the latter case, we apply the usual outer boundary conditions.
1. The numerical grids must be staggered to avoid direct evaluation of the equations on coordinate singularities.

```python
# Part P0: Set the number of ghost cells, from NRPy+'s FD_CENTDERIVS_ORDER
with open("ScalarWaveCurvilinear/ScalarWaveCurvilinear_NGHOSTS.h", "w") as file:
    file.write("// Part P0: Set the number of ghost cells, from NRPy+'s FD_CENTDERIVS_ORDER\n")
    file.write("#define NGHOSTS "+str(int(par.parval_from_str("finite_difference::FD_CENTDERIVS_ORDER")/2))+"\n")
```

```python
%%writefile ScalarWaveCurvilinear/ScalarWaveCurvilinear_Playground.c

// Step P0: Set NGHOSTS, the number of ghost cells, from NRPy+'s FD_CENTDERIVS_ORDER
#include "ScalarWaveCurvilinear_NGHOSTS.h"

// Step P1: Import needed header files
#include "stdio.h"
#include "stdlib.h"
#include "math.h"

// Step P2: Add needed #define's to set data type, the IDX4() macro, and the gridfunctions
// Step P2a: set REAL=double, so that all floating point numbers are stored to at least ~16 significant digits.
#define REAL double

// Step P3: Set free parameters
// Step P3a: Free parameters for the numerical grid

// Spherical coordinates parameter
const REAL RMAX    = 10.0;
// SinhSpherical coordinates parameters
const REAL AMPL    = 10.0;
const REAL SINHW   = 0.125;
// Cylindrical coordinates parameters
const REAL ZMIN   = -10.0;
const REAL ZMAX   =  10.0;
const REAL RHOMAX =  10.0;

// Time coordinate parameters
const REAL t_final =  8.0; /* Final time is set so that at t=t_final,
                            * data at the origin have not been corrupted
                            * by the approximate outer boundary condition */
const REAL CFL_FACTOR = 1.0; // Set the CFL Factor

// Step P3b: Free parameters for the scalar wave evolution
const REAL wavespeed = 1.0;
const REAL kk0 = 1.0;
const REAL kk1 = 1.0;
const REAL kk2 = 1.0;

// Step P4: Declare the IDX4(gf,i,j,k) macro, which enables us to store 4-dimensions of
//           data in a 1D array. In this case, consecutive values of "i"
//           (all other indices held to a fixed value) are consecutive in memory, where
//           consecutive values of "j" (fixing all other indices) are separated by
//           Nxx_plus_2NGHOSTS[0] elements in memory. Similarly, consecutive values of
//           "k" are separated by Nxx_plus_2NGHOSTS[0]*Nxx_plus_2NGHOSTS[1] in memory, etc.
#define IDX4(g,i,j,k) \
( (i) + Nxx_plus_2NGHOSTS[0] * ( (j) + Nxx_plus_2NGHOSTS[1] * ( (k) + Nxx_plus_2NGHOSTS[2] * (g) ) ) )
#define IDX3(i,j,k) ( (i) + Nxx_plus_2NGHOSTS[0] * ( (j) + Nxx_plus_2NGHOSTS[1] * (k) ) )
// Part P2c: Set UUGF and VVGF macros
#define NUM_GFS 2
#define UUGF 0
#define VVGF 1

#define LOOP_REGION(i0min,i0max, i1min,i1max, i2min,i2max) \
  for(int i2=i2min;i2<i2max;i2++) for(int i1=i1min;i1<i1max;i1++) for(int i0=i0min;i0<i0max;i0++)

void xxCart(REAL *xx[3],const int i0,const int i1,const int i2, REAL xCart[3]) {
    REAL xx0 = xx[0][i0];
    REAL xx1 = xx[1][i1];
    REAL xx2 = xx[2][i2];
#include "xxCart.h"
}

// Step P5: Include basic functions needed to impose curvilinear
//          parity and boundary conditions.
// Contains generalized boundary condition driver for curvilinear coordinate systems:
#include "curvilinear_boundary_conditions.h"

// Step P6: Find the CFL-constrained timestep
REAL find_timestep(const int Nxx_plus_2NGHOSTS[3],const REAL dxx[3],REAL *xx[3], const REAL CFL_FACTOR) {
  const REAL dxx0 = dxx[0], dxx1 = dxx[1], dxx2 = dxx[2];
  REAL dsmin = 1e38; // Start with a crazy high value... close to the largest number in single precision.
  LOOP_REGION(NGHOSTS,Nxx_plus_2NGHOSTS[0]-NGHOSTS, NGHOSTS,Nxx_plus_2NGHOSTS[1]-NGHOSTS, NGHOSTS,Nxx_plus_2NGHOSTS[2]-NGHOSTS) {
    const REAL xx0 = xx[0][i0], xx1 = xx[1][i1], xx2 = xx[2][i2];
    REAL ds_dirn0, ds_dirn1, ds_dirn2;
#include "ds_dirn.h"
#define MIN(A, B) ( ((A) < (B)) ? (A) : (B) )
    // Set dsmin = MIN(dsmin, ds_dirn0, ds_dirn1, ds_dirn2);
    dsmin = MIN(dsmin,MIN(ds_dirn0,MIN(ds_dirn1,ds_dirn2)));
  }
  return dsmin*CFL_FACTOR/wavespeed;
}

// Part P7: Declare the function for the exact solution. time==0 corresponds to the initial data.
void exact_solution(const int Nxx_plus_2NGHOSTS[3],REAL time,REAL *xx[3], REAL *in_gfs) {
#pragma omp parallel for
  LOOP_REGION(0,Nxx_plus_2NGHOSTS[0], 0,Nxx_plus_2NGHOSTS[1], 0,Nxx_plus_2NGHOSTS[2]) {
    REAL xCart[3];
    xxCart(xx, i0,i1,i2, xCart);
    REAL xx0 = xCart[0];
    REAL xx1 = xCart[1];
    REAL xx2 = xCart[2];
#include "ScalarWaveCartesian_ExactSolution.h"
  }
}

// Part P6: Declare the function to evaluate the scalar wave RHSs
void rhs_eval(const int Nxx[3],const int Nxx_plus_2NGHOSTS[3],const REAL dxx[3], REAL *xx[3], const REAL *in_gfs,REAL *rhs_gfs) {
#include "ScalarWaveCurvilinear_RHSs.h"
}

// main() function:
// Step 0: Read command-line input, set up grid structure, allocate memory for gridfunctions, set up coordinates
// Step 1: Set up scalar wave initial data
// Step 2: Evolve scalar wave initial data forward in time using Method of Lines with RK4 algorithm,
//         applying quadratic extrapolation outer boundary conditions.
// Step 3: Output relative error between numerical and exact solution.
// Step 4: Free all allocated memory
int main(int argc, const char *argv[]) {

  // Step 0a: Read command-line input, error out if nonconformant
  if(argc != 4 || atoi(argv[1]) < NGHOSTS || atoi(argv[2]) < NGHOSTS || atoi(argv[3]) < NGHOSTS) {
      printf("Error: Expected one command-line argument: ./ScalarWaveCurvilinear_Playground Nx0 Nx1 Nx2,\n");
      printf("where Nx[0,1,2] is the number of grid points in the 0, 1, and 2 directions.\n");
      printf("Nx[] MUST BE larger than NGHOSTS (= %d)\n",NGHOSTS);
      exit(1);
  }
  // Step 0b: Set up numerical grid structure, first in space...
  const int Nxx[3] = { atoi(argv[1]), atoi(argv[2]), atoi(argv[3]) };
  if(Nxx[0]%2 != 0 || Nxx[1]%2 != 0 || Nxx[2]%2 != 0) {
    printf("Error: Cannot guarantee a proper cell-centered grid if number of grid cells not set to even number.\n");
    printf("       For example, in case of angular directions, proper symmetry zones will not exist.\n");
    exit(1);
  }
  const int Nxx_plus_2NGHOSTS[3] = { Nxx[0]+2*NGHOSTS, Nxx[1]+2*NGHOSTS, Nxx[2]+2*NGHOSTS };
  const int Nxx_plus_2NGHOSTS_tot = Nxx_plus_2NGHOSTS[0]*Nxx_plus_2NGHOSTS[1]*Nxx_plus_2NGHOSTS[2];
#include "xxminmax.h"

  // Step 0c: Allocate memory for gridfunctions
  REAL *evol_gfs    = (REAL *)malloc(sizeof(REAL) * NUM_GFS * Nxx_plus_2NGHOSTS_tot);
  REAL *next_in_gfs = (REAL *)malloc(sizeof(REAL) * NUM_GFS * Nxx_plus_2NGHOSTS_tot);
  REAL *k1_gfs   = (REAL *)malloc(sizeof(REAL) * NUM_GFS * Nxx_plus_2NGHOSTS_tot);
  REAL *k2_gfs   = (REAL *)malloc(sizeof(REAL) * NUM_GFS * Nxx_plus_2NGHOSTS_tot);
  REAL *k3_gfs   = (REAL *)malloc(sizeof(REAL) * NUM_GFS * Nxx_plus_2NGHOSTS_tot);
  REAL *k4_gfs   = (REAL *)malloc(sizeof(REAL) * NUM_GFS * Nxx_plus_2NGHOSTS_tot);

  // Step 0d: Set up space and time coordinates
  // Step 0d.i: Set \Delta x^i on uniform grids.
  REAL dxx[3];
  for(int i=0;i<3;i++) dxx[i] = (xxmax[i] - xxmin[i]) / ((REAL)Nxx[i]);

  // Step 0d.ii: Set up uniform coordinate grids
  REAL *xx[3];
  for(int i=0;i<3;i++) {
    xx[i] = (REAL *)malloc(sizeof(REAL)*Nxx_plus_2NGHOSTS[i]);
    for(int j=0;j<Nxx_plus_2NGHOSTS[i];j++) {
      xx[i][j] = xxmin[i] + ((REAL)(j-NGHOSTS) + (1.0/2.0))*dxx[i]; // Cell-centered grid.
    }
  }

  // Step 0d.iii: Set timestep based on smallest proper distance between gridpoints and CFL factor
  REAL dt = find_timestep(Nxx_plus_2NGHOSTS, dxx,xx, CFL_FACTOR);
  //printf("# Timestep set to = %e\n",(double)dt);
  int N_final = (int)(t_final / dt + 0.5); // The number of points in time.
                                           // Add 0.5 to account for C rounding down
                                           // typecasts to integers.

  // Step 0f: Find ghostzone mappings:
  //const int num_gz_tot = Nxx_plus_2NGHOSTS_tot - Nxx[0]*Nxx[1]*Nxx[2];
  gz_map *bc_gz_map = (gz_map *)malloc(sizeof(gz_map)*Nxx_plus_2NGHOSTS_tot);
  set_up_bc_gz_map(Nxx_plus_2NGHOSTS, xx, dxx, xxmin, xxmax, bc_gz_map);

  // Step 1: Set up initial data to be exact solution at time=0:
  exact_solution(Nxx_plus_2NGHOSTS, 0.0, xx, evol_gfs);

  for(int n=0;n<=N_final;n++) { // Main loop to progress forward in time.
    // Step 2: Evolve scalar wave initial data forward in time using Method of Lines with RK4 algorithm,
    //         applying quadratic extrapolation outer boundary conditions.
    /***************************************************/
    /* Implement RK4 for Method of Lines timestepping: */
    /***************************************************/
    /* -= RK4: Step 1 of 4 =- */
    /* First evaluate k1 = RHSs expression             */
    rhs_eval(Nxx,Nxx_plus_2NGHOSTS,dxx, xx,evol_gfs, k1_gfs);
    /* Next k1 -> k1*dt, and then set the input for    */
    /*    the next RHS eval call to y_n+k1/2           */
    for(int i=0;i<Nxx_plus_2NGHOSTS_tot*NUM_GFS;i++) {
      k1_gfs[i] *= dt;
      next_in_gfs[i] = evol_gfs[i] + k1_gfs[i]*0.5;
    }
    /* Finally, apply boundary conditions to           */
    /* next_in_gfs, so its data are set everywhere.    */
    apply_bcs(Nxx,Nxx_plus_2NGHOSTS,bc_gz_map,next_in_gfs);

    /* -= RK4: Step 2 of 4 =- */
    rhs_eval(Nxx,Nxx_plus_2NGHOSTS,dxx, xx,next_in_gfs, k2_gfs);
    for(int i=0;i<Nxx_plus_2NGHOSTS_tot*NUM_GFS;i++) {
      k2_gfs[i] *= dt;
      next_in_gfs[i] = evol_gfs[i] + k2_gfs[i]*0.5;
    }
    apply_bcs(Nxx,Nxx_plus_2NGHOSTS,bc_gz_map,next_in_gfs);

    /* -= RK4: Step 3 of 4 =- */
    rhs_eval(Nxx,Nxx_plus_2NGHOSTS,dxx, xx,next_in_gfs, k3_gfs);
    for(int i=0;i<Nxx_plus_2NGHOSTS_tot*NUM_GFS;i++) {
      k3_gfs[i] *= dt;
      next_in_gfs[i] = evol_gfs[i] + k3_gfs[i];
    }
    apply_bcs(Nxx,Nxx_plus_2NGHOSTS,bc_gz_map,next_in_gfs);

    /* -= RK4: Step 4 of 4 =- */
    rhs_eval(Nxx,Nxx_plus_2NGHOSTS,dxx, xx,next_in_gfs, k4_gfs);
    for(int i=0;i<Nxx_plus_2NGHOSTS_tot*NUM_GFS;i++) {
      k4_gfs[i] *= dt;
      evol_gfs[i] += (1.0/6.0)*(k1_gfs[i] + 2.0*k2_gfs[i] + 2.0*k3_gfs[i] + k4_gfs[i]);
    }
    apply_bcs(Nxx,Nxx_plus_2NGHOSTS,bc_gz_map,evol_gfs);

    /* Step 3: Output relative error between numerical and exact solution, */
    const int i0MIN=NGHOSTS; // In spherical, r=Delta r/2.
    const int i1mid=Nxx_plus_2NGHOSTS[1]/2;
    const int i2mid=Nxx_plus_2NGHOSTS[2]/2;
    exact_solution(Nxx_plus_2NGHOSTS,(n+1)*dt, xx, k1_gfs);
    const double exact     = (double)k1_gfs[IDX4(0,i0MIN,i1mid,i2mid)];
    const double numerical = (double)evol_gfs[IDX4(0,i0MIN,i1mid,i2mid)];
    const double relative_error = fabs((exact-numerical)/exact);

   REAL log_max_error = -100.0;
   REAL xmax,ymax,zmax;
   for(int i2=0;i2<Nxx_plus_2NGHOSTS[2];i2++)
        for(int i1=0;i1<Nxx_plus_2NGHOSTS[1];i1++)
            for(int i0=0;i0<Nxx_plus_2NGHOSTS[0];i0++) {
                REAL xCart[3]; xxCart(xx,i0,i1,i2, xCart);

                if(sqrt(pow(xCart[0],2) + pow(xCart[1],2) + pow(xCart[2],2)) < RMAX*0.1) {
                    double num   = (double)evol_gfs[IDX4(0,i0,i1,i2)];
                    double exact = (double)k1_gfs[IDX4(0,i0,i1,i2)];
                    REAL log_rel_error = log10(fabs((num-exact)/exact));
                    if(log_rel_error > log_max_error) {
                        log_max_error = log_rel_error;
                        xmax = xCart[0];
                        ymax = xCart[1];
                        zmax = xCart[2];
                    }
                }
            }

    printf("%e %e %e || %e %e %e: %e %e\n",(double)((n+1)*dt), log_max_error,log10(relative_error),
           (double)xx[0][i0MIN],(double)xx[1][i1mid],(double)xx[2][i2mid], numerical,exact);
  } // End main loop to progress forward in time.

  // Step 4: Free all allocated memory
  free(bc_gz_map);
  free(k4_gfs);
  free(k3_gfs);
  free(k2_gfs);
  free(k1_gfs);
  free(next_in_gfs);
  free(evol_gfs);
  for(int i=0;i<3;i++) free(xx[i]);
  return 0;
}
```

    Overwriting ScalarWaveCurvilinear/ScalarWaveCurvilinear_Playground.c

## Code validation: Verify that relative error in numerical solution converges to zero at sixth-order

The numerical solution $u_{\rm num}(x0,x1,x2,t)$ should converge to the exact solution $u_{\rm exact}(x0,x1,x2,t)$ at sixth order, which means that
$$
u_{\rm num}(x0,x1,x2,t) = u_{\rm exact}(x0,x1,x2,t) + \mathcal{O}\left((\Delta x0)^6\right)+ \mathcal{O}\left((\Delta x0)^6\right)+ \mathcal{O}\left((\Delta x0)^6\right)+ \mathcal{O}\left((\Delta t)^6\right).
$$

Thus the relative error $E_{\rm rel}$ should satisfy:
$$
\left|\frac{u_{\rm num}(x0,x1,x2,t) - u_{\rm exact}(x0,x1,x2,t)}{u_{\rm exact}(x0,x1,x2,t)}\right| \propto \mathcal{O}\left((\Delta x0)^6\right)+ \mathcal{O}\left((\Delta x0)^6\right)+ \mathcal{O}\left((\Delta x0)^6\right)+ \mathcal{O}\left((\Delta t)^6\right).
$$

We confirm this convergence behavior by first solving the scalar wave equation at two resolutions: $20\times 10\times 20$, and $24\times 12\times 24$ and evaluating the maximum logaritmic relative error $\log_{10} E_{\rm rel,max}$ between numerical and exact solutions within a region $R < 0.1 {\rm RMAX}$ at all iterations.

Since we increase the resolution uniformly over all four coordinates $(x0,x1,x2,t)$, $E_{\rm rel}$ should drop uniformly as $(\Delta x0)^6$:
$$
E_{\rm rel} \propto (\Delta x0)^6.
$$

So at the two resolutions, we should find that
$$
\frac{E_{\rm rel}(20\times 10\times 20)}{E_{\rm rel}(24\times 12\times 24)} \approx \left(\frac{1/20}{1/24}\right)^6 = \left(\frac{24}{20}\right)^6 \approx 3.
$$

Since we're measuring logarithmic relative error, this should be
$$
\log_{10}\left(\frac{E_{\rm rel}(20\times 10\times 20)}{E_{\rm rel}(24\times 12\times 24)}\right) \approx \log_{10}(3).
$$

```python
!cd ScalarWaveCurvilinear/
!gcc -Ofast -march=native -ftree-parallelize-loops=2 -fopenmp ScalarWaveCurvilinear/ScalarWaveCurvilinear_Playground.c -o ScalarWaveCurvilinear_Playground -lm
!taskset -c 0,1 ./ScalarWaveCurvilinear_Playground 20 10 20 > out20x10x20.txt
!taskset -c 0,1 ./ScalarWaveCurvilinear_Playground 24 12 24 > out24x12x24.txt
```

```python
%matplotlib inline
import matplotlib.pyplot as plt
import mpmath as mp
import csv

def file_reader(filename):
    with open(filename) as file:
        reader = csv.reader(file, delimiter=" ")
        data  = list(zip(*reader))
        # data is a tuple of strings. Tuples are immutable, and we need to perform math on
        #   the data, so here we convert tuple to lists of floats:
        data0 = []
        data1 = []
        for i in range(len(data[0])):
            data0.append(float(data[0][i]))
            data1.append(float(data[1][i]))
        return data0,data1

first_col20,second_col20 = file_reader('out20x10x20.txt')
first_col24,second_col24 = file_reader('out24x12x24.txt')

for i in range(len(second_col20)):
    # data20 = data24*(20/24)**6
    # -> log10(data24) = log10(data24) + 6*log10(20/24) \approx log10(data24) - log10(3)
    second_col20[i] += 6*mp.log10(20./24.)

# https://matplotlib.org/gallery/text_labels_and_annotations/legend.html#sphx-glr-gallery-text-labels-and-annotations-legend-py
fig, ax = plt.subplots()

plt.title("Plot Demonstrating 6th-order Convergence")
plt.xlabel("time")
plt.ylabel("log10(Max relative error)")

ax.plot(first_col24, second_col24, 'k-', label='logErel(Nr=24)')
ax.plot(first_col20, second_col20, 'k--', label='logErel(Nr=20) + log((20/24)^6)')
legend = ax.legend(loc='lower right', shadow=True, fontsize='x-large')
legend.get_frame().set_facecolor('C1')
plt.show()
```