Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Atmosphere models for h_max to X_max conversion #2000

Merged
merged 54 commits into from
Sep 28, 2022
Merged
Show file tree
Hide file tree
Changes from 45 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
8d26dbf
first pass of some atmosphere density classes
kosack Jun 15, 2022
790b8cc
add TableAtmosphereDensityProfile
kosack Jun 15, 2022
43aec81
add line_of_sight_integral for zenith dependence
kosack Jul 8, 2022
717b56f
added Provenance for input file
kosack Jul 8, 2022
fca30b1
add __all__ to __init__.py
kosack Jul 8, 2022
01f3266
fix bug in units in peek()
kosack Jul 8, 2022
9961508
fix small typo in formula
kosack Jul 8, 2022
0f68b73
set log scale by default in peek()
kosack Jul 8, 2022
95f5630
fix doc
kosack Jul 8, 2022
c5bfba4
check for missing atmospheric profiles and fix doc
kosack Jul 11, 2022
dbff36d
added test for low level read
kosack Jul 11, 2022
f47862d
moved io routines to SimTelEventSource
kosack Jul 27, 2022
b5d232c
EventSource now only provides a single profile
kosack Jul 11, 2022
b3e1240
use cubic spline interpolation and log space
kosack Jul 12, 2022
035ba83
improve tests
kosack Jul 12, 2022
19cc0cf
assume only 1 profile in simtel file
kosack Jul 13, 2022
0445385
change syntax for quantity_support
kosack Jul 13, 2022
8d4add7
add more examples in docs
kosack Jul 13, 2022
47cf980
attempt to fix docs
kosack Jul 13, 2022
3609a0c
move some docs into docstring
kosack Jul 13, 2022
33a7c5b
increased hight range in peek()
kosack Jul 13, 2022
eda92bb
use linspace in peek
kosack Jul 13, 2022
b087de5
another attempt to fix docs
kosack Jul 13, 2022
a77a7da
fix return type in docstring
kosack Jul 13, 2022
11d33fe
added FiveLayerAtmosphereDensityProfile
kosack Jul 18, 2022
b1bacb4
fix units in column 5
kosack Jul 18, 2022
b790625
add repr for FiveLayerAtmosphereDensityProfile
kosack Jul 18, 2022
5943a6e
removed unused code
kosack Jul 18, 2022
0c5867e
add table metadata
kosack Jul 18, 2022
dd0e2b7
fix missing return in test
kosack Jul 18, 2022
a68d908
added test against reference data from Konrad
kosack Jul 20, 2022
46d3c8f
fix some docstrings
kosack Jul 21, 2022
4b5862d
added a from_table factory method
kosack Jul 21, 2022
7387fa7
write and read back profiles to/from hdf5
kosack Jul 21, 2022
783ada3
Merge branch 'master' of https://github.com/cta-observatory/ctapipe i…
kosack Aug 3, 2022
c4e7b29
add eventio.*.SimTelFile to nitpick-ignored
kosack Aug 3, 2022
f034fdb
fixed typo
kosack Aug 3, 2022
0dce4bd
changed to a single file module rather than dir
kosack Aug 30, 2022
fbf2821
rename some variables
kosack Aug 30, 2022
cc7287a
better variable names to fix pylint warnings
kosack Aug 30, 2022
80e73fc
made profile type a choice in SimTelEventSource
kosack Aug 31, 2022
c6e2ce5
fix test to use AtmosphereProfileKind
kosack Sep 2, 2022
c24d4c7
fix a few style warnings
kosack Sep 2, 2022
6981fb4
cover peek() in tests
kosack Sep 2, 2022
a3dd2d0
move docstring to avoid codacy complaint
kosack Sep 5, 2022
34acc62
remove redundant raise of NotImplementedError
kosack Sep 26, 2022
6a01ab1
check for supported table version
kosack Sep 26, 2022
2991466
Merge branch 'master' of https://github.com/cta-observatory/ctapipe i…
kosack Sep 26, 2022
be92968
return fix in peek()
kosack Sep 26, 2022
bf8f867
better error handling of table version
kosack Sep 26, 2022
69993e6
warn on unserializable profile in datawriter
kosack Sep 26, 2022
026db62
better naming of metadata, htoa
kosack Sep 27, 2022
d045d0c
fix atmosphere_name: bytes to string
kosack Sep 27, 2022
a9c7922
removed incorrect comment
kosack Sep 27, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
389 changes: 389 additions & 0 deletions ctapipe/atmosphere.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,389 @@
#!/usr/bin/env python3

"""Atmosphere density models and functions to transform between column density
(X in grammage units) and height (meters) units.

Zenith angle is taken into account in the line-of-sight integral to compute the
column density X assuming Earth as a flat plane (the curvature is not taken into
account)

"""

import abc
from dataclasses import dataclass
from functools import partial
from typing import Dict

import numpy as np
from astropy import units as u
from astropy.table import Table
from scipy.interpolate import interp1d

__all__ = [
"AtmosphereDensityProfile",
"ExponentialAtmosphereDensityProfile",
"TableAtmosphereDensityProfile",
"FiveLayerAtmosphereDensityProfile",
]


class AtmosphereDensityProfile:
"""
Base class for models of atmosphere density.
"""

@abc.abstractmethod
def __call__(self, height: u.Quantity) -> u.Quantity:
"""
Returns
-------
u.Quantity["g cm-3"]
the density at height h
"""
raise NotImplementedError()
kosack marked this conversation as resolved.
Show resolved Hide resolved

@abc.abstractmethod
def integral(self, height: u.Quantity) -> u.Quantity:
r"""Integral of the profile along the height axis, i.e. the *atmospheric
depth* :math:`X`.

.. math:: X(h) = \int_{h}^{\infty} \rho(h') dh'

Returns
-------
u.Quantity["g/cm2"]:
Integral of the density from height h to infinity

"""
raise NotImplementedError()
kosack marked this conversation as resolved.
Show resolved Hide resolved

def line_of_sight_integral(
self, distance: u.Quantity, zenith_angle=0 * u.deg, output_units=u.g / u.cm**2
):
r"""Line-of-sight integral from the shower distance to infinity, along
the direction specified by the zenith angle. This is sometimes called
the *slant depth*. The atmosphere here is assumed to be Cartesian, the
curvature of the Earth is not taken into account.

.. math:: X(h, \Psi) = \int_{h}^{\infty} \rho(h' \cos{\Psi}) dh'

Parameters
----------
distance: u.Quantity["length"]
line-of-site distance from observer to point
zenith_angle: u.Quantity["angle"]
zenith angle of observation
output_units: u.Unit
unit to output (must be convertible to g/cm2)

"""

return (
self.integral(distance * np.cos(zenith_angle)) / np.cos(zenith_angle)
).to(output_units)

def peek(self):
"""
Draw quick plot of profile
"""
# pylint: disable=import-outside-toplevel
import matplotlib.pyplot as plt

fig, axis = plt.subplots(1, 3, constrained_layout=True, figsize=(10, 3))

fig.suptitle(self.__class__.__name__)
height = np.linspace(1, 100, 500) * u.km
density = self(height)
axis[0].set_xscale("linear")
axis[0].set_yscale("log")
axis[0].plot(height, density)
axis[0].set_xlabel(f"Height / {height.unit.to_string('latex')}")
axis[0].set_ylabel(f"Density / {density.unit.to_string('latex')}")
axis[0].grid(True)

distance = np.linspace(1, 100, 500) * u.km
for zenith_angle in [0, 40, 50, 70] * u.deg:
column_density = self.line_of_sight_integral(distance, zenith_angle)
axis[1].plot(distance, column_density, label=f"$\\Psi$={zenith_angle}")

axis[1].legend(loc="best")
axis[1].set_xlabel(f"Distance / {distance.unit.to_string('latex')}")
axis[1].set_ylabel(f"Column Density / {column_density.unit.to_string('latex')}")
axis[1].set_yscale("log")
axis[1].grid(True)

zenith_angle = np.linspace(0, 80, 20) * u.deg
for distance in [0, 5, 10, 20] * u.km:
column_density = self.line_of_sight_integral(distance, zenith_angle)
axis[2].plot(zenith_angle, column_density, label=f"Height={distance}")

axis[2].legend(loc="best")
axis[2].set_xlabel(
f"Zenith Angle $\\Psi$ / {zenith_angle.unit.to_string('latex')}"
)
axis[2].set_ylabel(f"Column Density / {column_density.unit.to_string('latex')}")
axis[2].set_yscale("log")
axis[2].grid(True)

plt.show()
kosack marked this conversation as resolved.
Show resolved Hide resolved

@classmethod
def from_table(cls, table: Table):
"""return a subclass of AtmosphereDensityProfile from a serialized
table"""

if "TAB_TYPE" not in table.meta:
kosack marked this conversation as resolved.
Show resolved Hide resolved
raise ValueError("expected a TAB_TYPE metadata field")

tabtype = table.meta.get("TAB_TYPE")

if tabtype == "ctapipe.atmosphere.TableAtmosphereDensityProfile":
return TableAtmosphereDensityProfile(table)
if tabtype == "ctapipe.atmosphere.FiveLayerAtmosphereDensityProfile":
return FiveLayerAtmosphereDensityProfile(table)

raise TypeError(f"Unknown AtmosphereDensityProfile type: '{tabtype}'")


@dataclass
class ExponentialAtmosphereDensityProfile(AtmosphereDensityProfile):
"""
A simple functional density profile modeled as an exponential.

The is defined following the form:

.. math:: \\rho(h) = \\rho_0 e^{-h/h_0}


.. code-block:: python

from ctapipe.atmosphere import ExponentialAtmosphereDensityProfile
density_profile = ExponentialAtmosphereDensityProfile()
density_profile.peek()


Attributes
----------
scale_height: u.Quantity["m"]
scale height (h0)
scale_density: u.Quantity["g cm-3"]
scale density (rho0)
"""

scale_height: u.Quantity = 8 * u.km
scale_density: u.Quantity = 0.00125 * u.g / (u.cm**3)

@u.quantity_input(height=u.m)
def __call__(self, height) -> u.Quantity:
return self.scale_density * np.exp(-height / self.scale_height)

@u.quantity_input(height=u.m)
def integral(
self,
height,
) -> u.Quantity:
return (
self.scale_density * self.scale_height * np.exp(-height / self.scale_height)
)


class TableAtmosphereDensityProfile(AtmosphereDensityProfile):
"""Tabular profile from a table that has both the density and it's integral
pre-computed. The table is interpolated to return the density and its integral.

.. code-block:: python

from astropy.table import Table
from astropy import units as u

from ctapipe.atmosphere import TableAtmosphereDensityProfile

table = Table(
dict(
height=[1,10,20] * u.km,
density=[0.00099,0.00042, 0.00009] * u.g / u.cm**3
column_density=[1044.0, 284.0, 57.0] * u.g / u.cm**2
)
)

profile = TableAtmosphereDensityProfile(table=table)
print(profile(10 * u.km))


Attributes
----------
table: Table
Points that define the model

See Also
--------
ctapipe.io.eventsource.EventSource.atmosphere_density_profile:
load a TableAtmosphereDensityProfile from a supported EventSource
"""

def __init__(self, table: Table):
"""
Parameters
----------
table: Table
Table with columns `height`, `density`, and `column_density`
"""

for col in ["height", "density", "column_density"]:
if col not in table.colnames:
raise ValueError(f"Missing expected column: {col} in table")

self.table = table[
(table["height"] >= 0)
& (table["density"] > 0)
& (table["column_density"] > 0)
]

# interpolation is done in log-y to minimize spline wobble

self._density_interp = interp1d(
self.table["height"].to("km").value,
np.log10(self.table["density"].to("g cm-3").value),
kind="cubic",
)
self._col_density_interp = interp1d(
self.table["height"].to("km").value,
np.log10(self.table["column_density"].to("g cm-2").value),
kind="cubic",
)

# ensure it can be read back
self.table.meta["TAB_TYPE"] = "ctapipe.atmosphere.TableAtmosphereDensityProfile"
self.table.meta["TAB_VER"] = 1

@u.quantity_input(height=u.m)
def __call__(self, height) -> u.Quantity:
return u.Quantity(
10 ** self._density_interp(height.to_value(u.km)), u.g / u.cm**3
)

@u.quantity_input(height=u.m)
def integral(self, height) -> u.Quantity:
return u.Quantity(
10 ** self._col_density_interp(height.to_value(u.km)), u.g / u.cm**2
)

def __repr__(self):
return (
f"{self.__class__.__name__}(meta={self.table.meta}, rows={len(self.table)})"
)


# Here we define some utility functions needed to build the piece-wise 5-layer
# model.

# pylint: disable=invalid-name,unused-argument
def _exponential(h, a, b, c):
"""exponential atmosphere"""
return a + b * np.exp(-h / c)


def _d_exponential(h, a, b, c):
"""derivative of exponential atmosphere"""
return -b / c * np.exp(-h / c)


def _linear(h, a, b, c):
"""linear atmosphere"""
return a - b * h / c


def _d_linear(h, a, b, c):
"""derivative of linear atmosphere"""
return -b / c


class FiveLayerAtmosphereDensityProfile(AtmosphereDensityProfile):
r"""
CORSIKA 5-layer atmosphere model

Layers 1-4 are modeled with:

.. math:: T(h) = a_i + b_i \exp{-h/c_i}

Layer 5 is modeled with:

..math:: T(h) = a_5 - b_5 \frac{h}{c_5}

References
----------
[corsika-user] D. Heck and T. Pierog, "Extensive Air Shower Simulation with CORSIKA:
A User’s Guide", 2021, Appendix F
"""

def __init__(self, table: Table):
self.table = table

param_a = self.table["a"].to("g/cm2")
param_b = self.table["b"].to("g/cm2")
param_c = self.table["c"].to("km")

# build list of column density functions and their derivatives:
self._funcs = [
partial(f, a=param_a[i], b=param_b[i], c=param_c[i])
for i, f in enumerate([_exponential] * 4 + [_linear])
]
self._d_funcs = [
partial(f, a=param_a[i], b=param_b[i], c=param_c[i])
for i, f in enumerate([_d_exponential] * 4 + [_d_linear])
]

@classmethod
def from_array(cls, array: np.ndarray, metadata: Dict = None):
"""construct from a 5x5 array as provided by eventio"""

if metadata is None:
metadata = {}

if array.shape != (5, 5):
raise ValueError("expected ndarray with shape (5,5)")

table = Table(
array,
names=["height", "a", "b", "c", "1/c"],
units=["cm", "g/cm2", "g/cm2", "cm", "cm-1"],
meta=metadata,
)

table.meta.update(
dict(
TAB_VER=1,
TAB_TYPE="ctapipe.atmosphere.FiveLayerAtmosphereDensityProfile",
)
)
return cls(table)

@u.quantity_input(height=u.m)
def __call__(self, height) -> u.Quantity:
which_func = np.digitize(height, self.table["height"]) - 1
condlist = [which_func == i for i in range(5)]
return u.Quantity(
-1
* np.piecewise(
height,
condlist=condlist,
funclist=self._d_funcs,
)
).to(u.g / u.cm**3)

@u.quantity_input(height=u.m)
def integral(self, height) -> u.Quantity:
which_func = np.digitize(height, self.table["height"]) - 1
condlist = [which_func == i for i in range(5)]
return u.Quantity(
np.piecewise(
x=height,
condlist=condlist,
funclist=self._funcs,
)
).to(u.g / u.cm**2)

def __repr__(self):
return (
f"{self.__class__.__name__}(meta={self.table.meta}, rows={len(self.table)})"
)
Loading