Source code for toycoronagraph.config

"""
Parameters of Target (one image through one coronagraph), read from toycoronagraph_para.toml.

This is the parameter reader of the older, flat configuration used by Target (main.py);
the scene framework (toycoronagraph.observe) uses toycoronagraph.params directly.

Two layouts are read:
  * the scene layout of toycoronagraph.params ([observation], [grid], [star], [[source]],
    [[coronagraph]], ...): checked there, then mapped onto the flat configuration below
    (one image source; the first coronagraph);
  * the older layout ([target], [telescope], [coronagraph], [method], [compute]) in SCHEMA;
    its [coronagraph] type is "vortex" or "lazuli". A lazuli [coronagraph] has the keys of the
    scene layout's "lazuli" type and is checked with its declarations
    (toycoronagraph.params.CORONAGRAPHS); its own keys (LAZULI_KEYS) are refused under a vortex.
Every key is optional. Keys set to "auto" are filled from the input image and its FITS
header (resolve(), called by inputs.read_target). The values actually used are written to
<parameter file>_used.toml (write_used); the parameter file itself is not modified unless
[run] write_back = true. An old-style toycoronagraph_para.py is still read (with a notice)
when no .toml file exists. If there is no parameter file at all, a minimal one is created
(toycoronagraph.params_docs.new_config).
"""
import os
import importlib.util
from types import SimpleNamespace
try:
    import tomllib # Python >= 3.11
except ModuleNotFoundError: # pragma: no cover
    import tomli as tomllib
from toycoronagraph.params import CORONAGRAPHS, Param, convert, coronagraph_problems

PARA_FILE = "toycoronagraph_para.toml"
LEGACY_PARA_FILE = "toycoronagraph_para.py"
RAD_TO_ARCSEC = 206264.806247
LIGHT_SPEED = 299792458 # m/s
JY = 1e26 # W / m^2 / Hz to Jy
MISMATCH_TOL = 0.01 # relative difference between parameters and header that is reported
RESOLVED_MARK = "# ---- Values used in the last run, after checking the input (written by toycoronagraph; not read back) ----"

AUTO = "auto"
UNITS = ("W/m^2/pixel", "Jy/arcsec^2", "Jy/pixel")
# MCFOST image cubes (RT.fits.gz): axis 0 holds these 8 planes (FLUX_1 ... FLUX_8)
MCFOST_COMPONENTS = {"total": 0, "I": 0, "Q": 1, "U": 2, "V": 3, "direct_star": 4, "scattered_star": 5,
                     "direct_thermal": 6, "scattered_thermal": 7}
# "disk": all the light of the disk, i.e. the total minus the direct starlight (the star itself)
MCFOST_CHOICES = ("disk",)+tuple(MCFOST_COMPONENTS)
# [coronagraph] keys used only by type = "lazuli": declared (and checked) as in toycoronagraph.params
LAZULI_KEYS = ("wavelengths", "throughput", "opd", "pupil_samples")

# section -> key -> (default, allowed values / type / "auto|<type>" / "index list" / a params.Param, description)
SCHEMA = {
    "target": {
        "fits_filename": ("", str, "FITS file of the target (\".fits\" is added if no extension is given); \"\" uses the example target"),
        "input_format": (AUTO, (AUTO, "image", "mcfost"), "\"image\": a 2-D image; \"mcfost\": an MCFOST image cube; \"auto\" recognizes MCFOST cubes"),
        "mcfost_component": ("scattered_star", MCFOST_CHOICES, "MCFOST plane: \"disk\" = total minus the star; \"total\" (I) includes the star itself"),
        "mcfost_inclination": (0, int, "MCFOST: index of the inclination"),
        "mcfost_azimuth": (0, int, "MCFOST: index of the azimuth"),
        "fits_hdu": (0, int, "FITS extension (HDU) holding the data"),
        "fits_slice": (AUTO, "index list", "explicit indices into the leading axes, e.g. [5, 0, 0] (overrides the MCFOST keys); \"auto\": from input_format"),
        "input_unit": (AUTO, (AUTO,)+UNITS, "unit of the data; \"auto\" reads the BUNIT header keyword"),
        "target_pixel": (AUTO, "auto|int", "pixels on one edge of the (square) image; \"auto\" uses the image size"),
        "unit": ("none", str, "colorbar label; \"none\" uses Jy/arcsec^2"),
        "symmetric": (AUTO, (AUTO, True, False), "is the target circularly symmetric about the star? \"auto\" measures it"),
    },
    "telescope": {
        "wavelength": (AUTO, "auto|float", "observing wavelength in m; \"auto\" reads WAVELEN (m) or WAVE (um) from the header, else 1e-6"),
        "diameter": (2.4, float, "aperture diameter D, in meters"),
        "visual_range": (AUTO, "auto|float", "width of the image in lambda/D; \"auto\" follows the header's pixel scale (PIXSCALE, or CDELT), else 32"),
    },
    "coronagraph": {
        "type": ("vortex", ("vortex", "lazuli"), "coronagraph model: \"vortex\", or \"lazuli\" (the Lazuli telescope, prysm)"),
        "charge": (2, int, "vortex charge (positive, even); lazuli: 0 = the telescope alone (its default when "
                           "charge is left out) or even; used when a method is called without one"),
        "lyot_mask_size": (0.95, float, "Lyot stop diameter relative to the aperture"),
        "symmetric": (AUTO, (AUTO, True, False), "are the PSFs circularly symmetric? \"auto\" uses the optics model"),
        **{p.name: (p.default, p, "lazuli: " + p.doc) for p in CORONAGRAPHS["lazuli"][1] if p.name in LAZULI_KEYS},
    },
    "method": {
        "method": (AUTO, (AUTO, "circular", "spatial"), "\"auto\": circular if the target and the PSFs are both symmetric, else spatial"),
        "spatial_mode": (AUTO, (AUTO, "rotate", "direct"), "\"auto\": rotate for symmetric PSFs, else direct"),
        "error_target": (0.02, float, "target relative error of the final image (spatial: sets kernel size and grid)"),
        "symmetry_tol": (0.01, float, "measured target asymmetry above which \"auto\" treats it as not symmetric"),
        "rot_number": (0, int, "circular method: number of rotations; 0 uses target_pixel"),
    },
    "compute": {
        "num_cores": (-1, int, "CPU cores for parallel work; -1 uses all"),
        "use_gpu": (False, bool, "use CuPy for the spatial convolutions"),
        "max_kernel_gb": (4.0, float, "largest spatial kernel cube to build, in GB"),
        "psf_cache_dir": ("", str, "folder for computed PSFs; \"\" uses the current folder"),
    },
}

USAGE_NOTE = """\
# Using your own target
#   * An MCFOST image (e.g. data_0.6/RT.fits.gz): set fits_filename to it; the cube is
#     recognized, and mcfost_component / mcfost_inclination / mcfost_azimuth pick the plane.
#     Use "disk" (or "scattered_star", ...) rather than "total": the total includes the star
#     itself, which the coronagraph is meant to remove. The header gives the unit, pixel
#     scale and wavelength.
#   * Any other image: save a FITS file with a square 2-D array (or a cube and set
#     fits_slice), the star at the centre of the array ((N-1)/2 in 0-based pixels), and
#     header keywords BUNIT (W/m^2/pixel, Jy/arcsec^2 or Jy/pixel), PIXSCALE (arcsec per
#     pixel; or CDELT1/CDELT2 in degrees) and WAVELEN (m) - or set input_unit / wavelength
#     / visual_range here instead.
#   The image pixel scale must match the PSF sampling: with visual_range = "auto" it does.
#   The values actually used are saved to <this file's name>_used.toml (this file is not modified).
"""


def _toml_value(value):
    """
    A value as TOML text (lists/tuples as arrays, booleans as true/false, strings quoted).

    Args:
        value: A str, bool, int, float, or a list/tuple of these.

    Returns:
        str: The TOML literal (strings are not escaped).
    """
    if isinstance(value, (list, tuple)):
        return "[%s]" % ", ".join(_toml_value(v) for v in value)
    if isinstance(value, bool):
        return "true" if value else "false"
    if isinstance(value, str):
        return '"%s"' % value
    return repr(value)


[docs] def example_text(): """ The text of a commented example toycoronagraph_para.toml in the older layout (all defaults). Returns: str: The file content: a header with USAGE_NOTE, then every SCHEMA section and key with its default and description (the LAZULI_KEYS commented out: the type is "vortex"). """ lines = ["# Toy Coronagraph parameters. Every key is optional; these are the defaults.", "#", USAGE_NOTE] for section, keys in SCHEMA.items(): lines.append("[%s]" % section) for key, (default, _, description) in keys.items(): line = "%-18s = %-16s # %s" % (key, _toml_value(default), description) lines.append("# " + line if section == "coronagraph" and key in LAZULI_KEYS else line) lines.append("") return "\n".join(lines)
[docs] def write_example(path=PARA_FILE): """ Create an example parameter file in the older layout (example_text); overwrites path. Args: path (str): The file to write. """ with open(path, "w") as f: f.write(example_text())
def _is_number(value, kind): """ Whether value is a number of the given kind (booleans are not numbers). Args: value: Any value. kind (type): int (only int accepted) or float (int or float accepted). Returns: bool """ if isinstance(value, bool): return False return isinstance(value, int) if kind is int else isinstance(value, (int, float)) def _check(section, key, value): """ Check one value against SCHEMA and convert it. Args: section (str): Section name in SCHEMA. key (str): Key name in that section. value: The value read from the file (or the default). Returns: The value: "auto" kept as is; numbers converted to int/float as the schema says; keys declared by a params.Param converted by params.convert (e.g. "632 nm" to m). Raises: ValueError: If the value does not match the schema. """ _, allowed, _ = SCHEMA[section][key] if isinstance(allowed, Param): return convert(allowed, value, "[%s]" % section) if allowed == "index list": if value == AUTO or (isinstance(value, list) and all(_is_number(v, int) for v in value)): return value raise ValueError("[%s] %s = %r: must be \"auto\" or a list of integers, e.g. [5, 0, 0]" % (section, key, value)) if isinstance(allowed, str) and allowed.startswith("auto|"): kind = int if allowed == "auto|int" else float if value == AUTO: return value if _is_number(value, kind): return kind(value) raise ValueError("[%s] %s = %r: must be \"auto\" or a%s %s" % (section, key, value, "n" if kind is int else "", kind.__name__)) if isinstance(allowed, tuple): # booleans must match booleans exactly (in Python, True == 1 and False == 0) if value not in allowed or (isinstance(value, bool) != any(isinstance(a, bool) and a == value for a in allowed)): raise ValueError("[%s] %s = %r: must be one of %s" % (section, key, value, ", ".join(map(_toml_value, allowed)))) return value if allowed is float and _is_number(value, float): return float(value) # (bool is a subclass of int: a boolean is only accepted where bool is expected) if not isinstance(value, allowed) or (allowed is not bool and isinstance(value, bool)): raise ValueError("[%s] %s = %r: must be a %s" % (section, key, value, allowed.__name__)) return value
[docs] def from_dict(data, source="parameters"): """ Validate a parsed TOML dict and return the flat configuration (not yet resolved). Args: data (dict): {section: {key: value}}; a [resolved] section is ignored. source (str): Name used in messages (the file path). Returns: SimpleNamespace: every parameter, flat (the two `symmetric` keys become image_symmetric and psf_symmetric, [coronagraph] type becomes coronagraph_type, fits_filename "" becomes None, diameter is also stored as D). "auto" values stay "auto" until resolve(). Also has source (str) and legacy (False). Raises: ValueError: For an unknown section or key, a lazuli-only key (LAZULI_KEYS) under another type, a value that does not match SCHEMA (lazuli: the declarations of that type), a vortex charge that is not positive and even, a lazuli charge that is not even, or a lazuli throughput without one weight per wavelength. """ for section, keys in data.items(): if section == "resolved": continue if section not in SCHEMA: raise ValueError("%s: unknown section [%s]; known: %s" % (source, section, ", ".join(SCHEMA))) if not isinstance(keys, dict): raise ValueError("%s: [%s] must be a table of keys" % (source, section)) for key in keys: if key not in SCHEMA[section]: raise ValueError("%s: unknown key [%s] %s; known: %s" % (source, section, key, ", ".join(SCHEMA[section]))) coronagraph = data.get("coronagraph", {}) # a lazuli [coronagraph] is checked with that type's declarations (its own defaults: charge 0, the telescope alone) lazuli = {p.name: p for p in CORONAGRAPHS["lazuli"][1]} if coronagraph.get("type") == "lazuli" else {} extra = [key for key in LAZULI_KEYS if key in coronagraph and not lazuli] if extra: raise ValueError("%s: [coronagraph] %s: only used with type = \"lazuli\"" % (source, ", ".join(extra))) rename = {("target", "symmetric"): "image_symmetric", ("coronagraph", "symmetric"): "psf_symmetric", ("coronagraph", "type"): "coronagraph_type"} cfg = SimpleNamespace(source=source, legacy=False) for section, keys in SCHEMA.items(): for key, (default, _, _) in keys.items(): declared = lazuli.get(key) if section == "coronagraph" else None if declared is None: value = _check(section, key, data.get(section, {}).get(key, default)) else: value = convert(declared, coronagraph.get(key, declared.default), "[coronagraph]") setattr(cfg, rename.get((section, key), key), value) cfg.D = cfg.diameter cfg.fits_filename = cfg.fits_filename or None if cfg.coronagraph_type == "vortex" and (cfg.charge <= 0 or cfg.charge%2): raise ValueError("[coronagraph] charge = %r: a vortex charge must be positive and even" % cfg.charge) if lazuli: problems = coronagraph_problems("lazuli", {key: getattr(cfg, key) for key in ("charge", "wavelengths", "throughput")}) if problems: raise ValueError("[coronagraph] " + "; ".join(problems)) return cfg
def _header_unit(header): """ The unit named by the header's BUNIT, restricted to UNITS. Args: header: FITS header (dict-like). Returns: tuple: unit (str or None): One of UNITS, or None for any other (or no) BUNIT. raw (str): The BUNIT text as written ("" if absent). """ from toycoronagraph.units import header_unit unit, raw = header_unit(header) return (unit if unit in UNITS else None), raw def _header_pixel_scale(header): """ The pixel scale from PIXSCALE (arcsec) or CDELT2/CDELT1 (unit from CUNIT, default deg). Args: header: FITS header (dict-like). Returns: tuple: scale (float or None): arcsec per pixel (absolute value); None if not found or the CUNIT is not understood. key (str or None): Where it came from, e.g. "PIXSCALE" or "CDELT2 (deg)". """ if "PIXSCALE" in header: return abs(float(header["PIXSCALE"])), "PIXSCALE" for axis in ("2", "1"): if "CDELT" + axis in header: unit = str(header.get("CUNIT" + axis, "deg")).strip().lower() factor = {"deg": 3600.0, "arcmin": 60.0, "arcsec": 1.0, "mas": 1e-3, "rad": RAD_TO_ARCSEC}.get(unit) if factor is not None: return abs(float(header["CDELT" + axis]))*factor, "CDELT%s (%s)" % (axis, unit) return None, None def _header_wavelength(header): """ The wavelength from WAVELEN (m) or MCFOST's WAVE (um). Args: header: FITS header (dict-like). Returns: tuple: wavelength (float or None): In m (WAVE rounded to 12 significant digits after the um -> m conversion); None if neither keyword is present. key (str or None): "WAVELEN" or "WAVE (um)". """ if "WAVELEN" in header: return float(header["WAVELEN"]), "WAVELEN" if "WAVE" in header: return float("%.12g" % (float(header["WAVE"])*1e-6)), "WAVE (um)" return None, None def _differs(a, b): """ Whether a differs from the reference b by more than MISMATCH_TOL (relative to b). Args: a, b (float): The value and the reference. Returns: bool """ return abs(a-b) > MISMATCH_TOL*abs(b)
[docs] def resolve(cfg, image_shape, header=None): """ Fill the "auto" parameters from the input image and its header, check the explicit ones against the header, and compute the derived quantities (psf_scale, F_transfer, psf_range, rot_number). Sets (in cfg): target_pixel, wavelength (m), visual_range (lambda/D across the image), psf_scale (arcsec/pixel), F_transfer (factor from nu F_nu in W/m^2/pixel to F_nu in Jy/arcsec^2), psf_range (lambda/D, half of visual_range), input_unit, rot_number (target_pixel if <= 0), sources ({parameter: where its value came from}) and notes. For a legacy (.py) configuration, wavelength, visual_range, psf_scale, F_transfer and psf_range are kept as read. Args: cfg (SimpleNamespace): From load_config / from_dict; modified in place. image_shape (tuple): Shape of the (sliced) input image; must be square and 2-D. header: The image's FITS header (a dict-like), or None. Returns: list: Messages: a summary of the input (where each value came from), then any mismatch (WARNING lines). Also stored as cfg.notes, with cfg.sources. Raises: ValueError: If the image is not square and 2-D, target_pixel does not match it, or input_unit is "auto" and the header has no known BUNIT. """ header = header or {} notes = [] sources = {} if len(image_shape) != 2 or image_shape[0] != image_shape[1]: raise ValueError("the input image must be square and 2-D (got shape %s); check fits_hdu, fits_slice " "or the MCFOST keys" % (image_shape,)) n = image_shape[0] if cfg.target_pixel == AUTO: cfg.target_pixel, sources["target_pixel"] = n, "image size" elif cfg.target_pixel != n: raise ValueError("target_pixel = %d but the input image is %d x %d; set target_pixel = \"auto\" or match the image" % (cfg.target_pixel, n, n)) else: sources["target_pixel"] = "parameters (matches the image)" if cfg.legacy: # an old .py file: its own derived values are used as they are sources.update(wavelength="old .py file", visual_range="old .py file", psf_scale="old .py file") else: h_wave, h_wave_key = _header_wavelength(header) if cfg.wavelength == AUTO: cfg.wavelength, sources["wavelength"] = (h_wave, "header " + h_wave_key) if h_wave else (1.0e-6, "default (no wavelength in the header)") else: sources["wavelength"] = "parameters" if h_wave and _differs(cfg.wavelength, h_wave): notes.append("WARNING: wavelength %.4g m (parameters) differs from the header's %.4g m (%s)" % (cfg.wavelength, h_wave, h_wave_key)) lambda_d = cfg.wavelength/cfg.diameter*RAD_TO_ARCSEC # arcsec h_scale, h_scale_key = _header_pixel_scale(header) if cfg.visual_range == AUTO: if h_scale: cfg.psf_scale = h_scale cfg.visual_range = n*h_scale/lambda_d sources["visual_range"] = sources["psf_scale"] = "header " + h_scale_key else: cfg.visual_range = 32.0 cfg.psf_scale = lambda_d*cfg.visual_range/n sources["visual_range"] = sources["psf_scale"] = "default (no pixel scale in the header)" else: cfg.psf_scale = lambda_d*cfg.visual_range/n sources["visual_range"] = "parameters" sources["psf_scale"] = "from visual_range" if h_scale and _differs(cfg.psf_scale, h_scale): notes.append("WARNING: the PSFs are sampled at %.5g arcsec/pixel (visual_range = %g) but the image header says %.5g (%s): " "the target would be scaled wrongly; use visual_range = \"auto\"" % (cfg.psf_scale, cfg.visual_range, h_scale, h_scale_key)) cfg.F_transfer = JY*cfg.wavelength/LIGHT_SPEED/cfg.psf_scale**2 # nu F_nu (W/m^2/pixel) to F_nu (Jy/arcsec^2) cfg.psf_range = cfg.visual_range/2 h_unit, h_unit_raw = _header_unit(header) if cfg.input_unit == AUTO: if h_unit is None: raise ValueError("the unit of the input image is unknown (BUNIT = %r); set [target] input_unit to one of %s, " "or the BUNIT header keyword" % (h_unit_raw, ", ".join(UNITS))) cfg.input_unit, sources["input_unit"] = h_unit, "header BUNIT" else: sources["input_unit"] = "parameters" if h_unit is not None and h_unit != cfg.input_unit: notes.append("WARNING: input_unit %s (parameters) differs from the header's BUNIT = %r" % (cfg.input_unit, h_unit_raw)) if cfg.rot_number <= 0: cfg.rot_number = cfg.target_pixel cfg.sources = sources notes.insert(0, "Input: %s, %d x %d, %s (%s); %.5g arcsec/pixel (%s) = %.4g lambda/D across at %.4g m (%s)" % (getattr(cfg, "input_description", "image"), n, n, cfg.input_unit, sources["input_unit"], cfg.psf_scale, sources.get("psf_scale"), cfg.visual_range, cfg.wavelength, sources.get("wavelength"))) cfg.notes = notes return notes
[docs] def used_path(cfg): """ Where the values used are saved: <parameter file name>_used.toml next to it. Args: cfg (SimpleNamespace): A configuration (uses cfg.source). Returns: str: The path; "toycoronagraph_para_used.toml" when cfg.source is not a .toml or .py file. """ source = str(cfg.source) stem = os.path.splitext(source)[0] if source.endswith((".toml", ".py")) else "toycoronagraph_para" return stem + "_used.toml"
[docs] def write_used(cfg): """ Save the values used (after resolve()) to used_path(cfg); with write_back, also into the parameter file. Args: cfg (SimpleNamespace): A resolved configuration; cfg.write_back (if present and true) also writes the [resolved] section into cfg.source. Returns: str: The path written (used_path(cfg)). """ path = used_path(cfg) with open(path, "w") as f: f.write("# Values used by toycoronagraph for %s (written after each run; not read back)\n" % cfg.source) write_resolved(cfg, path) if getattr(cfg, "write_back", False): write_resolved(cfg) return path
[docs] def write_resolved(cfg, path=None): """ Write the values used (after resolve()) into a [resolved] section at the end of a .toml file, replacing the previous one; the rest of the file is kept as is. Args: cfg (SimpleNamespace): A resolved configuration. path (str): The .toml file; default cfg.source (skipped if it is not a .toml file). """ path = path or cfg.source if not str(path).endswith(".toml") or not os.path.exists(path): return with open(path) as f: text = f.read() if RESOLVED_MARK in text: text = text[:text.index(RESOLVED_MARK)] text = text.rstrip("\n")+"\n\n" lines = [RESOLVED_MARK, "[resolved]"] used = getattr(cfg, "used_slice", []) values = [("input", getattr(cfg, "input_description", "")), ("fits_slice", used if isinstance(used, list) else str(used)), ("input_unit", cfg.input_unit), ("target_pixel", cfg.target_pixel), ("wavelength", cfg.wavelength), ("visual_range", round(cfg.visual_range, 6)), ("psf_scale", cfg.psf_scale), ("psf_range", round(cfg.psf_range, 6)), ("rot_number", cfg.rot_number)] for key, value in values: source = cfg.sources.get(key, "") lines.append("%-13s = %-28s%s" % (key, _toml_value(value), ("# " + source) if source else "")) for note in cfg.notes[1:]: lines.append("# " + note) with open(path, "w") as f: f.write(text+"\n".join(lines)+"\n")
def _from_legacy(path): """ Read an old-style toycoronagraph_para.py (its derived values are taken as given). The file is executed as a Python module; its variables are mapped onto the older layout and checked with from_dict. psf_scale, F_transfer and psf_range are taken from the file if it defines them, else derived as in resolve(). Args: path (str): The .py parameter file. Returns: SimpleNamespace: The configuration, with legacy = True. """ spec = importlib.util.spec_from_file_location("toycoronagraph_para_legacy", path) par = importlib.util.module_from_spec(spec) spec.loader.exec_module(par) data = {"target": {"target_pixel": int(par.target_pixel)}, "telescope": {"wavelength": float(getattr(par, "wavelength", 1.0e-6)), "diameter": float(getattr(par, "D", 2.4)), "visual_range": float(getattr(par, "visual_range", 32.0))}, "coronagraph": {"type": getattr(par, "coronagraph_type", "vortex"), "lyot_mask_size": float(getattr(par, "lyot_mask_size", 0.95))}, "method": {"rot_number": int(getattr(par, "rot_number", 0))}, "compute": {"num_cores": int(getattr(par, "num_cores", -1))}} if getattr(par, "fits_filename", None): data["target"]["fits_filename"] = par.fits_filename if getattr(par, "unit", "none") != "none": data["target"]["unit"] = par.unit cfg = from_dict(data, source=path) cfg.legacy = True lambda_d = cfg.wavelength/cfg.diameter*RAD_TO_ARCSEC cfg.psf_scale = getattr(par, "psf_scale", lambda_d*cfg.visual_range/cfg.target_pixel) cfg.F_transfer = getattr(par, "F_transfer", JY*cfg.wavelength/LIGHT_SPEED/cfg.psf_scale**2) cfg.psf_range = getattr(par, "psf_range", cfg.visual_range/2) return cfg
[docs] def from_params(params): """ The old flat configuration (toycoronagraph.config) for Target, from new-layout parameters. Only files with exactly one enabled component, a fits_image or mcfost source, can be mapped; Target uses the first [[coronagraph]] (a note is added to params.notes when there are several). Args: params (toycoronagraph.params.Params): Checked new-layout parameters (params.load). Returns: SimpleNamespace: The configuration (see from_dict), with params (the Params object) and write_back ([run] write_back) added. Raises: ParamError: When the file needs the scene framework (not exactly one image source). """ from toycoronagraph.params import ParamError comps = params.enabled() if len(comps) != 1 or comps[0].source.kind not in ("fits_image", "mcfost"): raise ParamError("%s: Target simulates one image source (fits_image or mcfost); this file has %s. " "Use toycoronagraph.observe() for scenes with several components." % (params.path, ", ".join(c.name for c in comps) or "none")) kind, s = comps[0].source.kind, comps[0].source.settings # The image itself is read through the scene (toycoronagraph.scene); these keys only describe it target = {"fits_filename": "" if s["file"] == "example" else s["file"], "input_format": "image" if kind == "fits_image" else "mcfost"} if kind == "fits_image": unit = s["input_unit"] if s["input_unit"] in UNITS else AUTO target.update(fits_hdu=s["hdu"], fits_slice=s["slice"], input_unit=unit) else: target.update(mcfost_component=s["component"], mcfost_inclination=s["inclination"], mcfost_azimuth=s["azimuth"]) target["symmetric"] = s["symmetric"] size = params.get("grid.size") target["target_pixel"] = size if isinstance(size, int) else AUTO if params.get("display.label"): target["unit"] = params.get("display.label") telescope = {"wavelength": params.get("observation.wavelength"), "diameter": params.get("observation.diameter")} c = params.coronagraphs[0] coronagraph = {"type": c.type, **{k: v for k, v in c.values.items() if k not in ("type", "name")}} method = {k: v for k, v in params.sections.get("method", {}).items() if k not in ("combine", "error_metric")} data = {"target": target, "telescope": telescope, "coronagraph": coronagraph, "method": method, "compute": dict(params.sections.get("compute", {}))} cfg = from_dict(data, source=params.path) cfg.params = params cfg.write_back = params.get("run.write_back") if len(params.coronagraphs) > 1: params.notes.append("Note: Target uses the first [[coronagraph]] (%s) as its default; observe() uses all of them" % c.name) return cfg
[docs] def load_config(path=None, verbose=True): """ Load the parameters (not yet resolved against the input image; see resolve()). With no path: toycoronagraph_para.toml in the current directory; if it does not exist, an old toycoronagraph_para.py (with a notice); if neither exists, a minimal new-layout .toml is created (params_docs.new_config) and used. Args: path (str): A .toml (or legacy .py) parameter file. verbose (bool): Print notices. Returns: SimpleNamespace: The configuration (see from_dict; from_params for a new-layout file). """ if path is None: if os.path.exists(PARA_FILE): path = PARA_FILE elif os.path.exists(LEGACY_PARA_FILE): if verbose: print("Reading the old-style %s; toycoronagraph.config.write_example() creates a %s to replace it" % (LEGACY_PARA_FILE, PARA_FILE)) return _from_legacy(LEGACY_PARA_FILE) else: from toycoronagraph.params_docs import new_config new_config(["fits_image"], ["vortex:2"], path=PARA_FILE) if verbose: print("There is no %s file, created a minimal one (python -m toycoronagraph new --help: other templates)" % PARA_FILE) path = PARA_FILE if path.endswith(".py"): return _from_legacy(path) with open(path, "rb") as f: data = tomllib.load(f) from toycoronagraph import params if params.is_legacy(data) and "include" not in data: return from_dict(data, source=path) p = params.load(path) cfg = from_params(p) if verbose: for note in p.notes: print(note) return cfg