"""Manage pre-computed PDR models"""
import collections
import itertools
from copy import deepcopy
import astropy.units as u
import numpy as np
from astropy.table import Column, Table, unique, vstack
from .measurement import Measurement
from .utils import _OBS_UNIT_, get_table, model_dir
[docs]
class ModelSet:
"""Class for computed PDR Model Sets. :class:`ModelSet` provides interface to a directory containing the model FITS files and the ability to query details about.
Parameters
----------
name : str
Identifying name, e.g., 'wk2006'.
z : float
Metallicity in solar units.
medium : str
Medium type, e.g. 'constant density', 'clumpy', 'non-clumpy'.
losangle : float
The line-of-sight inclination angle (aka viewing angle), losangle=0 is
face-on, losangle=90 is edge-on. Valid values are 0, 30, 45, 60, 75, 90.
mass : float
Maximum clump mass (for KosmaTau models). Default: None (appropriate for
Wolfire/Kaufman models).
modelsetinfo : str or :class:`~astropy.table.Table`
For adding user specified ModelSet, this parameter specifies the file with
information about the ModelSet. It can be a pathname to an external tabular
file in an astropy-recognized format or an astropy Table object. If an
external tabular file, its format should be given by the `format` keyword.
The columns are:
.. code-block:: python
['PDRcode','name', 'version','path','filename','medium','z','inc', 'mass','description']
`z`, 'inc', and `mass` should be numbers, the rest should be strings.
The `name`, `version`, `medium`, `z`, 'inc', and `mass` columns contain
the values available in the input ModelSet as described above. `PDRcode`
is the originator of the code e.g., "KOSMA-tau", `version` is the code
version, `path` is the full-qualified pathname to the FITS files,
`filename` is the tabular file which contains descriptions of individual
FITS files. This must also be in the format specified by the `format`
keyword. `description` is a description of the input ModelSet.
format : str
If `modelsetinfo` is a file, give the format of file. Must be
`an astropy recognized Table format.
<https://docs.astropy.org/en/stable/io/unified.html#built-in-readers-writers>`_
e.g., ascii, ipac, votable. Default is `IPAC format
<https://docs.astropy.org/en/stable/api/astropy.io.ascii.Ipac.html#astropy.io.ascii.Ipac>`_
Raises
------
ValueError
If model set not recognized/found.
"""
def __init__(
self,
name,
z,
medium="constant density",
losangle=0,
mass=None,
modelsetinfo=None,
format="ipac",
debug=False,
):
if modelsetinfo is None:
# get the package default
self._all_models = get_table("all_models.tab")
elif isinstance(modelsetinfo, str):
self._all_models = Table.read(modelsetinfo, format=format)
else: # must be an Astropy Table
self._all_models = deepcopy(modelsetinfo)
self._all_models.add_index("name")
possible = dict()
if name not in self._all_models["name"]:
raise ValueError(
f"Unrecognized PDR model code {name:s}. Choices are: {set(list(self._all_models['name']))}"
)
if np.all(self._all_models.loc[name]["mass"].mask):
matching_rows = np.where(
(self._all_models["z"] == z)
& (self._all_models["medium"] == medium)
& (self._all_models["losangle"] == losangle)
)
possible["mass"] = None
else: # Kosma-tau
matching_rows = np.where(
(self._all_models["z"] == z)
& (self._all_models["medium"] == medium)
& (self._all_models["mass"] == mass)
)
possible["mass"] = self._all_models.loc[name]["mass"]
for key in ["z", "medium", "losangle"]:
possible[key] = self._all_models.loc[name][key]
# possible[] can be a Python native or a Column depending on how many rows matched.
# If only one row matches it will be a native, otherwise a Column.
# Check the type so we can create a sorted numpy array in either case.
for j in possible:
if possible[j] is None:
continue
if isinstance(possible[j], Column):
# convert Column to np.array
possible[j] = sorted(set(np.array(possible[j])))
else:
# convert native to np.array
possible[j] = sorted(set(np.array([possible[j]])))
if mass is None and possible["mass"] is not None:
raise ValueError(f"mass value is required for model {name:s}. Allowed values are {possible['mass']}")
if matching_rows[0].size == 0:
msg = (
f"Requested ModelSet not found in {name:s}. Check your input values. Allowed z are {possible['z']}. "
f" Allowed medium are {possible['medium']}. Allowed losangle are {possible['losangle']}."
)
if possible["mass"] is not None:
msg = msg + f" Allowed mass are {possible['mass']}."
raise ValueError(msg)
self._tabrow = self._all_models[matching_rows].loc[name]
tpath = self._tabrow["path"]
self._table = get_table(path=tpath, filename=self._tabrow["filename"], format=format)
self._table.add_index("ratio")
self._set_identifiers()
self._set_ratios()
self._default_unit = dict()
self._default_unit["ratio"] = u.dimensionless_unscaled
self._default_unit["intensity"] = _OBS_UNIT_
self._default_unit["emissivity"] = "erg / (cm3 ion s)"
self._user_added_models = dict()
self._av_table = get_table("av.tab", format="ascii")
self._av_table.add_index("LOSANGLE")
@property
def description(self):
"""The description of this model
Returns
-------
str
"""
s = f", Z={self.z:2.1f}, losangle={int(self.losangle):d}"
if self.avlos is not None:
s += f", avlos={self.avlos:.2f}"
if self.avperp is not None:
s += f", avperp={self.avperp:.2f}"
return self._tabrow["description"] + s
@property
def code(self):
"""The PDR code that created this ModelSet, e.g. 'KOSMA-tau'
Returns
-------
str
"""
return self._tabrow["PDRcode"]
@property
def name(self):
"""The name of this ModelSet
Returns
-------
str
"""
return self._tabrow["name"]
@property
def version(self):
"""The version of this ModelSet
Returns
-------
str
"""
return self._tabrow["version"]
@property
def medium(self):
"""The medium type of this model, e.g. 'constant density', 'clumpy', 'non-clumpy'
Returns
-------
str
"""
return self._tabrow["medium"]
@property
def z(self):
"""The metallicity of this ModelSet
Returns
-------
float
"""
return self.metallicity
@property
def metallicity(self):
"""The metallicity of this ModelSet
Returns
-------
float
"""
return self._tabrow["z"]
@property
def losangle(self):
"""The inclination (viewing) angle with respect to the line of sight, in degrees. A value of 0 means face-on.
Returns
-------
float
"""
return self._tabrow["losangle"]
@property
def viewangle(self):
"""The viewing angle (inclination) with respect to the line of sight, in degrees. A value of 0 means face-on.
Returns
-------
float
"""
return self.losangle
@property
def avperp(self):
"""The PDR thickness for inclined viewing angle models, expressed in visual magnitudes.
Returns ``None`` for face-on geometry (losangle=0) and for non-wk2020 models.
Returns
-------
float or None
"""
if not self.is_wk2020:
return None
avperp = float(self._av_table.loc[int(self.losangle)]["AVPERP"])
if avperp == 0.0:
return None
return avperp
@property
def avlos(self):
"""The visual extinction along the line of sight, expressed in magnitudes.
Returns ``None`` for non-wk2020 models.
Returns
-------
float or None
"""
if not self.is_wk2020:
return None
return float(self._av_table.loc[int(self.losangle)]["AVLOS"])
@property
def table(self):
"""The table containing details of the models in this ModelSet.
Returns
-------
:class:`astropy.table.Table`
"""
return self._table
@property
def identifiers(self):
"""Table of lines and continuum that are included in ratio models of this ModelSet. Only lines and continuum that are part of ratios are included in this list. For a separate list of line and continuum intensity models see :meth:`~pdrtpy.modelset.ModelSet.supported_intensities`.
Returns
-------
:class:`astropy.table.Table`
"""
return self._identifiers
@property
def supported_intensities(self):
"""Table of lines and continuum that are covered by this ModelSet and have models separate from
the any ratio model they might be in.
Returns
-------
:class:`astropy.table.Table`
"""
return self._supported_lines
@property
def supported_ratios(self):
"""The emission ratios that are covered by this ModelSet
Returns
-------
:class:`astropy.table.Table`
"""
return self._supported_ratios
@property
def user_added_models(self):
"""Show which models have been added to this ModelSet by the user
Returns
-------
list
"""
return list(self._user_added_models.keys())
[docs]
def ratiocount(self, m):
"""The number of valid ratios in this ModelSet, given a list of observation (:class:`~pdrtpy.measurement.Measurement`) identifiers.
Parameters
----------
m : list
List of string :class:`~pdrtpy.measurement.Measurement` IDs, e.g. ["CII_158","OI_145","FIR"].
Returns
-------
int
The number of model ratios found for the given list of measurement IDs.
"""
return len(self._get_ratio_elements(m))
[docs]
def find_pairs(self, m):
"""Find the valid model ratios labels in this ModelSet for a given list of measurement IDs
Parameters
----------
m : list
List of string `Measurement` IDs, e.g. ["CII_158","OI_145","FIR"].
Returns
-------
iterator
An iterator of model ratio labels for the given list of measurement IDs.
"""
if not isinstance(m, collections.abc.Iterable) or isinstance(m, (str, bytes)):
raise Exception("m must be an array of strings")
for q in itertools.product(m, m):
if q[0] == "FIR" and (q[1] == "OI_145" or q[1] == "OI_63") and "CII_158" in m:
s = q[1] + "+CII_158/" + q[0]
else:
s = q[0] + "/" + q[1]
if s in self.table["ratio"]:
yield s
[docs]
def find_files(self, m, ext="fits"):
"""Find the valid model ratios files in this ModelSet for a given list of measurement IDs. See :meth:`~pdrtpy.measurement.Measurement.id`
Parameters
----------
m : list
List of string :class:`~pdrtpy.measurement.Measurement` IDs, e.g. ["CII_158","OI_145","FIR"].
ext : str
File extension. Default: "fits".
Returns
-------
iterator
An iterator of model ratio files for the given list of Measurement IDs.
"""
if not isinstance(m, collections.abc.Iterable) or isinstance(m, (str, bytes)):
raise Exception("m must be an array of strings")
for q in itertools.product(m, m):
# must deal with OI+CII/FIR models. Note we must check for FIR first, since
# if you check q has OI,CII and m has FIR order you'll miss OI/CII.
if q[0] == "FIR" and (q[1] == "OI_145" or q[1] == "OI_63") and "CII_158" in m:
s = q[1] + "+CII_158/" + q[0]
else:
s = q[0] + "/" + q[1]
if s in self.table["ratio"]:
fullpath = self._tabrow["path"] + self.table.loc[s]["filename"] + "." + ext
tup = (s, fullpath)
yield tup
[docs]
def model_ratios(self, m):
"""Return the model ratios that match the input Measurement ID list. You must provide at least 2 Measurements IDs
Parameters
----------
m : list
List of string :class:`~pdrtpy.measurement.Measurement` IDs, e.g., ["CII_158","OI_145","FIR"].
Returns
-------
list
List of string identifiers of ratio IDs, e.g., ['OI_145/CII_158', 'OI_145+CII_158/FIR'].
"""
ratios = list()
if len(m) < 2:
raise Exception("m most contain at least two strings")
for p in self.find_files(m):
ratios.append(p[0])
return ratios
[docs]
def model_intensities(self, m):
"""Return the model intensities in this ModelSet that match the input Measurement ID list.
This method will return the intersection of the input list and the list of supported lines.
Parameters
----------
m : list
List of string :class:`~pdrtpy.measurement.Measurement` IDs, e.g., ["CII_158","OI_145","CS_21"].
Returns
-------
list
List of string identifiers of intensity IDs, e.g., ['CII_158','OI_145'].
"""
# get intersection of input list and supported lines
return list(set(m) & set(self._supported_lines["intensity label"]))
[docs]
def get_model(self, identifier, unit=None, ext="fits"):
"""Get a specific model by its identifier
Parameters
----------
identifier : str
A :class:`~pdrtpy.measurement.Measurement` ID. It can be an intensity or a ratio, e.g., "CII_158","CI_609/FIR".
Returns
-------
:class:`~pdrtpy.measurement.Measurement`
The model matching the identifier.
Raises
------
KeyError
If identifier not found in this ModelSet.
"""
if identifier in self._user_added_models:
return self._user_added_models[identifier]
if identifier not in self.table["ratio"]:
raise KeyError(f"{identifier} is not in this ModelSet")
_filename = self.table.loc[identifier]["filename"] + "." + ext
d = model_dir()
_thefile = d + self._tabrow["path"] + _filename
_title = self._table.loc[identifier]["title"]
# @TODO Fix this: see issues 66 & 67
if unit is None or unit == "":
if identifier == "TS":
unit = "K"
modeltype = "intensity" # ??'temperature'
# make a guess at the unit
elif "/" in identifier:
unit = self._default_unit["ratio"]
modeltype = "ratio"
else:
unit = self._default_unit["intensity"]
modeltype = "intensity" # this is wrong for emissivity modeltypes
elif unit == u.dimensionless_unscaled:
modeltype = "ratio"
else:
modeltype = "intensity"
_model = Measurement.read(_thefile, title=_title, unit=unit, identifier=identifier)
# if _model.unit=="":
# _model.unit = u.Unit("adu")#self._default_unit["ratio"]
_wcs = _model.wcs
if "MODELTYP" not in _model.header:
_model.header["MODELTYP"] = modeltype
_model.modeltype = modeltype
# @todo this is messy. clean up by doing if wcs.. first?
if self.is_wk2006 or self.name == "smc":
# fix WK2006 model headerslisthd
if _wcs.wcs.cunit[0] == "":
_model.header["CUNIT1"] = "cm^-3"
_wcs.wcs.cunit[0] = u.Unit("cm^-3")
else:
_model.header["CUNIT1"] = str(_wcs.wcs.cunit[0])
if _wcs.wcs.cunit[1] == "":
_model.header["CUNIT2"] = "Habing"
# Raises UnitScaleError:
# "The FITS unit format is not able to represent scales that are not powers of 10. Multiply your data by 1.600000e-03."
# This causes all sorts of downstream problems. Workaround in LineRatioFit.read_models().
# _model.wcs.wcs.cunit[1] = habing_unit
elif self.code == "KOSMA-tau":
# fix KosmaTau model headers
if _wcs.wcs.cunit[0] == "":
_model.header["CUNIT1"] = "cm^-3"
_wcs.wcs.cunit[0] = u.Unit("cm^-3")
else:
_model.header["CUNIT1"] = str(_wcs.wcs.cunit[0])
if _wcs.wcs.cunit[1] == "":
_model.header["CUNIT2"] = "Draine"
else:
_model.header["CUNIT2"] = str(_wcs.wcs.cunit[1])
else:
# copy wcs cunit to header. used later.
_model.header["CUNIT1"] = str(_wcs.wcs.cunit[0])
_model.header["CUNIT2"] = str(_wcs.wcs.cunit[1])
return _model
[docs]
def get_models(self, identifiers, model_type="ratio", ext="fits"):
"""Get the models from this ModelSet that match the input list of identifiers
Parameters
----------
identifiers : list
List of string :class:`~pdrtpy.measurement.Measurement` IDs, e.g., ["CII_158","OI_145","CS_21"].
model_type : str
Indicates which type of model is requested: one of 'ratio' or 'intensity'.
Returns
-------
list
The matching models as a list of :class:`~pdrtpy.measurement.Measurement`.
Raises
------
KeyError
If identifiers not found in this ModelSet.
"""
# if identifier not in self._identifiers["ID"]:
# raise Exception("There is no model in ModelSet %s with the identifier %s"%(self.name,identifier))
if model_type != "intensity" and model_type != "ratio" and model_type != "both":
raise ValueError("Unrecognized model_type: must be one of 'intensity', 'ratio', or 'both'")
models = dict()
a = list()
self._table.remove_indices("ratio")
self._table.add_index("ratio")
if model_type == "intensity" or model_type == "both":
a.extend(self.model_intensities(identifiers))
if model_type == "ratio" or model_type == "both":
a.extend(self.model_ratios(identifiers))
if model_type == "intensity" or model_type == "ratio":
_unit = self._default_unit[model_type]
else:
_unit = None
for k in a:
if k == "TS":
# kluge. we need to support model_type = "temperature"
models[k] = self.get_model(k, unit="K", ext=ext)
else:
models[k] = self.get_model(k, unit=_unit, ext=ext)
return models
[docs]
def add_model(self, identifier, model, title, overwrite=False):
r"""Add your own model to this ModelSet.
Parameters
----------
identifier : str
A :class:`~pdrtpy.measurement.Measurement` ID. It can be an intensity or a ratio, e.g., "CII_158","CI_609/FIR".
model : str or :class:`~pdrtpy.measurement.Measurement`
The model to add. If a string, this must be the fully-qualified path of a FITS file.
If a :class:`~pdrtpy.measurement.Measurement` it must have the same CTYPEs and CUNITs as the models in the ModelSet.
title : str
A formatted string (e.g., LaTeX) describing this observation that can be used for plotting.
Python r-strings are accepted, e.g., r'$^{13}$CO(3-2)' would give :math:`^{13}{\rm CO(3-2)}`.
overwrite : bool
Whether to overwrite the model if the identifier already exists in the ModelSet or has been
previously added. Default: False.
"""
if identifier not in self.table["ratio"] and identifier not in self._user_added_models:
self._really_add_model(identifier, model, title)
elif identifier in self._user_added_models and not overwrite:
raise Exception(
f"{identifier} was previously added to this ModelSet. If you wish to overwrite it, use overwrite=True"
)
elif identifier in self.table["ratio"] and not overwrite:
raise Exception(
f"{identifier} is already in the {self.name} ModelSet. If you wish to overwrite it, use overwrite=True"
)
else:
# print(f"Overwriting {identifier}.")
self._really_add_model(identifier, model, title)
def _really_add_model(self, identifier, model, title):
print(f"Adding user model {identifier}")
if type(model) is str:
m = Measurement.read(model, identifier=identifier)
else:
m = model
m._title = title
self._user_added_models[identifier] = m
if "/" in identifier: # it's a ratio
if identifier in self._supported_ratios["ratio label"]:
# ack, there should be a way just to replace title but I can't get Table.loc to work.
index = np.where(self._supported_ratios["ratio label"] == identifier)[0][0]
self._supported_ratios.remove_row(index)
self._supported_ratios.add_row([title, identifier])
numerator, denominator = identifier.split("/")
fakefilename = "user-" + numerator.replace("_", "") + "_" + denominator.replace("_", "")
else:
if identifier in self._supported_lines["intensity label"]:
index = np.where(self._supported_lines["intensity label"] == identifier)[0][0]
self._supported_lines.remove_row(index)
self._supported_lines.add_row([title, identifier])
numerator = identifier
denominator = 1
fakefilename = "user-" + numerator.replace("_", "")
# numerator denominator ratio filename z title
self.table.add_row([numerator, denominator, identifier, fakefilename, self.z, title, str(self.losangle)])
def _find_ratio_elements(self, m):
# TODO handle case of OI+CII/FIR so it is not special cased in lineratiofit.py
"""Find the valid model numerator,denominator pairs in this ModelSet for a given list of measurement IDs. See :meth:`~pdrtpy.measurement.Measurement.id`
Parameters
----------
m : list
List of string :class:`~pdrtpy.measurement.Measurement` IDs, e.g. ["CII_158","OI_145","FIR"].
Returns
-------
iterator
A dictionary iterator where key is numerator and value is denominator.
"""
if not isinstance(m, collections.abc.Iterable) or isinstance(m, (str, bytes)):
raise Exception("m must be an array of strings")
for q in itertools.product(m, m):
s = q[0] + "/" + q[1]
z = dict()
if s in self.table["ratio"]:
z = {"numerator": self.table.loc[s]["numerator"], "denominator": self.table.loc[s]["denominator"]}
yield z
def _get_ratio_elements(self, m):
"""Get the valid model numerator,denominator pairs in this ModelSet for a given list of measurement IDs. See :meth:`~pdrtpy.measurement.Measurement.id`
Parameters
----------
m : list
List of string :class:`~pdrtpy.measurement.Measurement` IDs, e.g. ["CII_158","OI_145","FIR"].
Returns
-------
dict
A dictionary where key is numerator and value is denominator.
"""
if not isinstance(m, collections.abc.Iterable) or isinstance(m, (str, bytes)):
raise Exception("m must be an array of strings")
k = list()
for q in itertools.product(m, m):
s = q[0] + "/" + q[1]
if s in self.table["ratio"]:
z = {"numerator": self.table.loc[s]["numerator"], "denominator": self.table.loc[s]["denominator"]}
k.append(z)
self._get_oi_cii_fir(m, k)
return k
def _get_oi_cii_fir(self, m, k):
"""Utility method for determining ratio elements, to handle special
case of ([O I] 63 micron + [C II] 158 micron)/I_FIR.
Parameters
----------
m : list
List of string :class:`~pdrtpy.measurement.Measurement` IDs, e.g. ["CII_158","OI_145","FIR"].
k : list
List of existing ratios to append to.
"""
if "CII_158" in m and "FIR" in m:
if "OI_63" in m:
num = "OI_63+CII_158"
den = "FIR"
# lab="OI_63+CII_158/FIR"
z = {"numerator": num, "denominator": den}
k.append(z)
if "OI_145" in m:
num = "OI_145+CII_158"
den = "FIR"
# lab="OI_145+CII_158/FIR"
z = {"numerator": num, "denominator": den}
k.append(z)
def _set_ratios(self):
"""make a useful table of ratios covered by this model"""
self._supported_ratios = Table([self.table["title"], self.table["denominator"], self.table["ratio"]], copy=True)
matching_rows = np.where(self._supported_ratios["denominator"] == "1")[0]
self._supported_lines = Table(self._supported_ratios[matching_rows], copy=True)
self._supported_lines.remove_column("denominator")
self._supported_ratios.remove_rows(matching_rows)
self._supported_ratios["title"].unit = None
self._supported_ratios["ratio"].unit = None
self._supported_ratios.remove_column("denominator")
self._supported_ratios.rename_column("ratio", "ratio label")
self._supported_lines.rename_column("ratio", "intensity label")
def _set_identifiers(self):
"""make a useful table of identifiers of lines covered by ratios in this ModelSet"""
# remove the single line intensity models from the list.
matching_rows = np.where(self._table["denominator"] != "1")[0]
n = deepcopy(self._table["numerator"][matching_rows])
n.name = "ID"
d = deepcopy(self._table["denominator"][matching_rows])
d.name = "ID"
t1 = Table([self._table["title"][matching_rows], n], copy=True)
# discard the summed fluxes as user would input them individually
for id in ["OI_145+CII_158", "OI_63+CII_158"]:
a = np.where(t1["ID"] == id)[0]
for z in a:
t1.remove_row(z)
# now remove denominator from title (everything from / onwards)
for i in range(len(t1["title"])):
if "/" in t1["title"][i]:
t1["title"][i] = t1["title"][i][0 : t1["title"][i].index("/")]
t2 = Table([self._table["title"][matching_rows], d], copy=True)
# remove numerator from title (everything before and including /)
for i in range(len(t2["title"])):
if "/" in t2["title"][i]:
t2["title"][i] = t2["title"][i][t2["title"][i].index("/") + 1 :]
t = vstack([t1, t2])
t = unique(t, keys=["ID"], keep="first", silent=True)
t["title"].unit = None
t["ID"].unit = None
t.rename_column("title", "canonical name")
self._identifiers = t
@property
def is_wk2006(self):
"""Indicate this is a wk2006 model, to deal with quirks of that modelset.
Returns
-------
bool
True if it is.
"""
return self.name == "wk2006"
@property
def is_wk2020(self):
"""Indicate this is a wk2020 model, to deal with quirks of that modelset.
Returns
-------
bool
True if it is.
"""
return self.name == "wk2020"
# ============= Static Methods =============
[docs]
@staticmethod
def list():
"""Print the names and descriptions of available ModelSets (not just this one)"""
ModelSet.all_sets().pprint_all(align="<")
[docs]
@staticmethod
def all_sets(debug=False):
"""Return a table of the names and descriptions of available ModelSets (not just this one)
Returns
-------
:class:`~astropy.table.Table`
"""
t = get_table("all_models.tab")
t.remove_column("path")
t.remove_column("filename")
return t