"""Contains base class for effects."""
from pathlib import Path
from ..effects.data_container import DataContainer
from .. import base_classes as bc
from ..utils import from_currsys, write_report
from ..reports.rst_utils import table_to_rst
[docs]class Effect(DataContainer):
"""
Base class for representing the effects (artifacts) in an optical system.
The ``Effect`` class is conceived to independently apply the changes that
an optical component (or series thereof) has on an incoming 3D description
of an on-sky object. In other words, **an Effect object should receive a
derivative of a ``Source`` object, alter it somehow, and return it**.
The interface for the Effect base-class has been kept very general so that
it can easily be sub-classed as data for new effects becomes available.
Essentially, a sub-classed Effects object must only contain the following
attributes:
* ``self.meta`` - a dictionary to contain meta data.
* ``self.apply_to(obj, **kwargs)`` - a method which accepts a
Source-derivative and returns an instance of the same class as ``obj``
* ``self.fov_grid(which="", **kwargs)``
Parameters
----------
See :class:`DataContainer` for input parameters
"""
def __init__(self, cmds=None, **kwargs):
super().__init__(**kwargs)
self.meta["z_order"] = []
self.meta["include"] = True
self.meta.update(kwargs)
[docs] def apply_to(self, obj, **kwargs):
"""TBA."""
if not isinstance(obj, (bc.FOVSetupBase, bc.SourceBase,
bc.FieldOfViewBase, bc.ImagePlaneBase,
bc.DetectorBase)):
raise ValueError("object must one of the following: FOVSetupBase, "
"Source, FieldOfView, ImagePlane, Detector: "
f"{type(obj)}")
return obj
[docs] def fov_grid(self, which="", **kwargs):
"""
Return the edges needed to generate FieldOfViews for an observation.
Parameters
----------
which : str
["waveset", "edges", "shifts"] where:
* waveset - wavelength bin extremes
* edges - on sky coordinate edges for each FOV box
* shifts - wavelength dependent FOV position offsets
kwargs
------
wave_min, wave_max : float
[um] list of wavelength
wave_mid : float
[um] wavelength what will be centred on optical axis
Returns
-------
waveset : list
[um] N+1 wavelengths that set edges of N spectral bins
edges : list of lists
[arcsec] Contains a list of footprint lists
shifts : list of 3 lists
[wave, dx, dy] Contains lists corresponding to the (dx, dy) offset
from the optical axis (0, 0) induced for each wavelength in (wave)
[um, arcsec, arcsec]
"""
self.update(**kwargs)
return []
[docs] def update(self, **kwargs):
self.meta.update(kwargs)
# self.update_bang_keywords()
# def update_bang_keywords(self):
# for key in self.meta:
# if isinstance(self.meta[key], str) and self.meta[key][0] == "!":
# bang_key = self.meta[key]
# self.meta[key] = rc.__currsys__[bang_key]
@property
def include(self):
return from_currsys(self.meta["include"], self.cmds)
@include.setter
def include(self, item):
self.meta["include"] = item
@property
def display_name(self):
name = self.meta.get("name", self.meta.get("filename", "<untitled>"))
if not hasattr(self, "_current_str"):
return name
return f"{name} : [{from_currsys(self.meta[self._current_str], self.cmds)}]"
@property
def meta_string(self):
padlen = 4 + len(max(self.meta, key=len))
exclude = {"comments", "changes", "description", "history",
"report_table_caption", "report_plot_caption", "table"}
meta_str = "\n".join(f"{key:>{padlen}} : {value}"
for key, value in self.meta.items()
if key not in exclude)
return meta_str
[docs] def report(self, filename=None, output="rst", rst_title_chars="*+",
**kwargs):
"""
For Effect objects, generates a report based on the data and meta-data.
This is to aid in the automation of the documentation process of the
instrument packages in the IRDB.
.. note:: If the Effect can generate a plot, this will be saved to disc
Parameters
----------
filename : str, optional
Where to save the RST file
output : str, optional
["rst", "latex"] Output file format
rst_title_chars : 2-str, optional
Two unique characters used to denote rst subsection headings.
Options: = - ` : ' " ~ ^ _ * + # < >
Additional parameters
---------------------
Either from the ``self.meta["report"]`` dictionary or via ``**kwargs``
"report_table_include": False
"report_table_caption": ""
"report_plot_caption": ""
"report_plot_include": False
"report_plot_file_formats": ["png"]
Multiple formats can be saved. The last entry is used for the RST.
"report_plot_filename": None
If None, uses self.meta["name"] as the filename
"file_description": str
Taken from the header of a file, if available
"class_description": str
Taken from the docstring of the subclass
"changes_str": list of str
Take from the header of a file, if available
Returns
-------
rst_str : str
The full reStructureText string
Notes
-----
The format of the RST output is as follows::
<ClassType>: <effect name>
**************************
File Description: <description for file meta data>
Class Description: <description from class docstring>
Changes: <list of changes from file meta data>
Data
++++
.. figure:: <Figure_name>.png
If the <Effect> object contains a ``.plot()`` function, add
plot and write it to disc
Figure caption
Table caption
Table
If the <Effect> object contains a ``.table()`` function, add
a pprint version of the table
Meta-data
+++++++++
::
A code block print out of the ``.meta`` dictionary
"""
changes = self.meta.get("changes", [])
changes_str = "- " + "\n- ".join(str(entry) for entry in changes)
cls_doc = self.__doc__ if self.__doc__ is not None else "<no docstring>"
cls_descr = cls_doc.lstrip().splitlines()[0]
params = {
"report_plot_filename": None,
"report_plot_file_formats": ["png"],
"report_plot_caption": "",
"report_plot_include": False,
"report_table_include": False,
"report_table_caption": "",
"report_table_rounding": None,
"report_image_path": "!SIM.reports.image_path",
"report_rst_path": "!SIM.reports.rst_path",
"report_latex_path": "!SIM.reports.latex_path",
"file_description": self.meta.get("description",
"<no description>"),
"class_description": cls_descr,
"changes_str": changes_str,
}
params.update(self.meta)
params.update(kwargs)
params = from_currsys(params, self.cmds)
rst_str = f"""
{str(self)}
{rst_title_chars[0] * len(str(self))}
**Included by default**: ``{params["include"]}``
**File Description**: {params["file_description"]}
**Class Description**: {params["class_description"]}
**Changes**:
{params["changes_str"]}
Data
{rst_title_chars[1] * 4}
"""
if params["report_plot_include"] and hasattr(self, "plot"):
from matplotlib.figure import Figure
fig = self.plot()
# HACK: plot methods should always return the same, while this is
# not sorted out, deal with both fig and ax
if not isinstance(fig, Figure):
fig = fig.figure
if fig is not None:
path = params["report_image_path"]
fname = params["report_plot_filename"]
if fname is None:
fname = self.meta["name"].lower().replace(" ", "_")
for fmt in params["report_plot_file_formats"]:
fname = ".".join((fname.split(".")[0], fmt))
file_path = Path(path, fname)
fig.savefig(fname=file_path)
# rel_path = os.path.relpath(params["report_image_path"],
# params["report_rst_path"])
# rel_file_path = os.path.join(rel_path, fname)
# TODO: fname is set in a loop above, so using it here in the
# fstring will only access the last value from the loop,
# is that intended?
rst_str += f"""
.. figure:: {fname}
:name: {"fig:" + params.get("name", "<unknown Effect>")}
{params["report_plot_caption"]}
"""
if params["report_table_include"]:
rst_str += f"""
.. table::
:name: {"tbl:" + params.get("name")}
{table_to_rst(self.table, indent=4, rounding=params["report_table_rounding"])}
{params["report_table_caption"]}
"""
rst_str += f"""
Meta-data
{rst_title_chars[1] * 9}
::
{self.meta_string}
"""
write_report(rst_str, filename, output)
return rst_str
[docs] def info(self):
"""Print basic information on the effect, notably the description."""
if (desc := self.meta.get("description")) is not None:
print(f"{self}\nDescription: {desc}")
else:
print(self)
def __repr__(self):
return f"{self.__class__.__name__}(**{self.meta!r})"
def __str__(self):
return f"{self.__class__.__name__}: \"{self.display_name}\""
def __getitem__(self, item):
if isinstance(item, str) and item.startswith("#"):
if len(item) > 1:
if item.endswith("!"):
key = item.removeprefix("#").removesuffix("!")
if len(key) > 0:
value = from_currsys(self.meta[key], self.cmds)
else:
value = from_currsys(self.meta, self.cmds)
else:
value = self.meta[item.removeprefix("#")]
else:
value = self.meta
else:
raise ValueError(f"__getitem__ calls must start with '#': {item}")
return value
def _get_path(self):
if any(key not in self.meta for key in ("path", "filename_format")):
return None
return Path(self.meta["path"],
from_currsys(self.meta["filename_format"], self.cmds))