Source code for pdrtpy.tool.lineratiofit

import cProfile
import io
import pstats
import warnings
from concurrent.futures import ProcessPoolExecutor, as_completed
from copy import deepcopy

import astropy.stats as astats

# import astropy.wcs as wcs
import astropy.units as u
import numpy as np
import numpy.ma as ma
from astropy import log

# from astropy.io import fits
from astropy.io.fits.header import Header
from astropy.nddata import CCDData
from astropy.table import Column, Table
from lmfit import Minimizer, Parameters  # , fit_report
from scipy.interpolate import interpn
from scipy.optimize import least_squares as _scipy_least_squares

from pdrtpy.pbar import get_progress_bar

from .. import utils
from .fitmap import FitMap
from .toolbase import ToolBase

log.setLevel("WARNING")  # see issue 163

# ---------------------------------------------------------------------------
# Module-level helpers for parallel pixel fitting.
# Must be at module level so ProcessPoolExecutor can pickle them.
# ---------------------------------------------------------------------------

# Per-worker-process cache of RegularGridInterpolators.  Set once by _init_worker.
_worker_model_interps = None


def _init_worker(model_points, model_values):
    """Initializer for ProcessPoolExecutor workers.  Builds RegularGridInterpolators
    from plain numpy arrays once per worker process so they are not rebuilt for
    every pixel."""
    from scipy.interpolate import RegularGridInterpolator

    global _worker_model_interps
    _worker_model_interps = [
        RegularGridInterpolator(pts, vals, method="linear", bounds_error=True)
        for pts, vals in zip(model_points, model_values, strict=False)
    ]


def _fit_pixel_worker(
    j, obs_data_j, obs_err_j, init_density, init_rf, minn, maxn, minfuv, maxfuv, nan_policy, minimize_kwargs
):
    """Fit a single spatial pixel.  Runs in a worker process spawned by
    ProcessPoolExecutor.  Uses the interpolators set up by _init_worker.

    Returns (j, MinimizerResult)."""
    import numpy as _np
    from lmfit import Minimizer as _Minimizer, Parameters as _Parameters

    def _residual(params):
        parvals = params.valuesdict()
        d = parvals["density"]
        rf = parvals["radiation_field"]
        mvalue = _np.array([float(interp((d, rf))) for interp in _worker_model_interps])
        return (obs_data_j - mvalue) / obs_err_j

    params = _Parameters()
    params.add("density", min=minn, max=maxn, value=init_density)
    params.add("radiation_field", min=minfuv, max=maxfuv, value=init_rf)
    minimizer = _Minimizer(_residual, params=None, nan_policy=nan_policy)
    return j, minimizer.minimize(params=params, **minimize_kwargs)


# ---------------------------------------------------------------------------
# Per-pixel proxy for joint fitting results.
# Presents the same interface as lmfit.MinimizerResult so that
# fit_result[j].params, fit_result[j].chisqr, etc. work unchanged.
# ---------------------------------------------------------------------------


class _PixelResult:
    """Thin per-pixel view into a joint MinimizerResult.

    Returned by fit_result[j] when joint_fit='hybrid' or 'fast' was used.  Exposes the
    same attributes as a full lmfit.MinimizerResult for the two fitted
    parameters (density, radiation_field) of a single pixel."""

    def __init__(self, density_val, density_err, rf_val, rf_err, chisqr, redchi, ndata, success, residual):
        self.params = Parameters()
        self.params.add("density", value=density_val)
        self.params["density"].stderr = density_err
        self.params.add("radiation_field", value=rf_val)
        self.params["radiation_field"].stderr = rf_err
        self.chisqr = chisqr
        self.redchi = redchi
        self.success = success
        self.residual = residual
        self.method = "least_squares"
        self.nvarys = 2
        self.ndata = ndata
        self.nfree = max(ndata - 2, 1)
        self.nfev = None  # not meaningful per-pixel in a joint fit
        self.errorbars = density_err is not None and rf_err is not None


[docs] class LineRatioFit(ToolBase): """Tool to fit observations of intensity ratios to a set of PDR models. Takes as input a set of observations with errors represented as :class:`~pdrtpy.measurement.Measurement` and a :class:`~pdrtpy.modelset.ModelSet` for the models to fit. Observations should be spectral line or continuum intensities, either spatial maps or single pixel values at the same spatial resolution. At least 3 observations are needed to make at least 2 ratios. Once the fit is done, :class:`~pdrtpy.plot.LineRatioPlot` can be used to view the results. Parameters ---------- modelset : :class:`~pdrtpy.modelset.ModelSet` The set of PDR models to use for fitting. measurements : list or dict of :class:`~pdrtpy.measurement.Measurement`, optional Input measurements to be fit. If dict, keys should be Measurement identifiers. """ def __init__(self, modelset, measurements=None): super().__init__() # needed? if isinstance(modelset, str): # may need to disable this self._initialize_modelTable(modelset) self._modelset = modelset self._init_measurements(measurements) self._set_measurementnaxis() self._modelratios = None self._modelnaxis = None self._set_model_files_used() self._observedratios = None self._chisq = None self._reduced_chisq = None self._likelihood = None self._radiation_field = None self._density = None self.radiation_field_unit = None self.radiation_field_type = None self.density_unit = None self.density_type = None self._fitresult = None self._fitparam = None self._minimizer = None self._deltasq = None self._ratiocount = None # phase-space limits (issue #236): (lower, upper) in linear model units, # populated by _normalize_phase_space_limits. Default None = unrestricted. self._density_range = None self._radiation_field_range = None @property def fit_result(self): """The result of the fitting procedure, including fit statistics, variable values and uncertainties, and correlations. One :class:`lmfit.minimizer.MinimizerResult` per pixel. Returns ------- :class:`~pdrtpy.tool.FitMap` """ return self._fitresult @property def modelset(self): """The underlying :class:`~pdrtpy.modelset.ModelSet`""" return self._modelset @property def measurements(self): """The stored measurements as a dictionary with Measurement IDs as keys. Returns ------- dict of :class:`~pdrtpy.measurement.Measurement` """ return self._measurements @property def measurementIDs(self): """The stored measurement IDs, which are strings. Returns ------- :class:`dict_keys` """ if self._measurements is None: return None return self._measurements.keys() @property def observed_ratios(self): """The list of the observed line ratios that have been input so far. Returns ------- list of str """ return list(self._observedratios.keys()) @property def ratiocount(self): """The number of ratios that match models available in the current :class:`~pdrtpy.modelset.ModelSet` given the current set of measurements. Returns ------- int """ # call to modelset._get_ratio_MA 01938elements is expensive. So set it once for each run. if self._ratiocount is None: self._ratiocount = self._modelset.ratiocount(self.measurementIDs) return self._ratiocount @property def density(self): """The computed hydrogen nucleus density value(s). Returns ------- :class:`~pdrtpy.measurement.Measurement` """ return self._density @property def radiation_field(self): """The computed radiation field value(s). Returns ------- :class:`~pdrtpy.measurement.Measurement` """ return self._radiation_field
[docs] def chisq(self, min=False): r"""The computed chisquare value(s). Parameters ---------- min : bool, optional If True, return the minimum :math:`\chi^2`. For map inputs, returns a spatial map of the minimum; for single-pixel inputs, returns a scalar. If False, returns the full :math:`\chi^2` as a function of density and radiation field. Default: False. Returns ------- :class:`~pdrtpy.measurement.Measurement` """ if min: return self._chisq_min else: return self._chisq
[docs] def reduced_chisq(self, min=False): r"""The computed reduced chisquare value(s). Parameters ---------- min : bool, optional If True, return the minimum :math:`\chi_\nu^2`. For map inputs, returns a spatial map of the minimum; for single-pixel inputs, returns a scalar. If False, returns the full :math:`\chi_\nu^2` as a function of density and radiation field. Default: False. Returns ------- :class:`~pdrtpy.measurement.Measurement` """ if min: return self._reduced_chisq_min else: return self._reduced_chisq
def _init_measurements(self, m): """Initialize the measurements from an input list or dict. Parameters ---------- m : list, tuple, or dict The input Measurements. If dict, keys must be valid measurement identifiers. """ self._masks = dict() # need to save these so they can be reset later if m is None: self._measurements = None elif isinstance(m, list) or isinstance(m, tuple): self._measurements = dict() for mm in m: self._measurements[mm.id] = mm self._masks[mm.id] = deepcopy(mm.mask) elif isinstance(m, dict): self._measurements = deepcopy(m) for key in m: self._masks[key] = deepcopy(m[key].mask) else: raise ValueError("Input measurements must be list, tuple, or dict") def _set_model_files_used(self): self._model_files_used = dict() if self._measurements is None: return for x in self._modelset.find_files(self.measurementIDs): self._model_files_used[x[0]] = x[1] def _check_shapes(self, d): # ugly s1 = d[utils.firstkey(d)].shape return np.all([m.shape == s1 for m in d.values()]) def _check_header(self, kw, value=NotImplemented): """Check to see if any of the given keyword values differ for the input measurements. Parameters ---------- kw : str The keyword to check. value : any, optional If given and not ``NotImplemented``, check that all *kw* values equal this. The default is ``NotImplemented`` (not ``None``) so that ``None`` can be checked against explicitly. """ d = self._measurements fk = utils.firstkey(d) in_wcs = False try: if value == NotImplemented: h = d[fk].header if kw not in h: # CTYPES etc can also be in WCS so check that too s1 = d[fk].wcs.to_header()[kw] in_wcs = True else: s1 = h[kw] else: s1 = value if in_wcs: return np.all([m.wcs.to_header()[kw] == s1 for m in d.values()]) else: return np.all([m.header[kw] == s1 for m in d.values()]) except KeyError: print(f"WARNING: {kw} keyword not present in all Measurements") return False def _check_measurement_shapes(self): if self._measurements is None: return False return self._check_shapes(self._measurements) def _check_ratio_shapes(self): if self._observedratios is None: return False return self._check_shapes(self._observedratios) def _check_model_shapes(self): if self._modelratios is None: return False return self._check_shapes(self._modelratios)
[docs] def add_measurement(self, m): r"""Add a Measurement to the internal dictionary used to compute ratios. The measurement may be in intensity units (:math:`{\rm erg~s}^{-1}` :math:`{\rm cm}^{-2}`) or integrated intensity (K km/s). Parameters ---------- m : :class:`~pdrtpy.measurement.Measurement` A Measurement instance to be added to this tool. """ if self._measurements: self._measurements[m.id] = m else: self._init_measurements(m) self._set_model_files_used()
[docs] def remove_measurement(self, id): """Delete a measurement from the internal dictionary used to compute ratios. Parameters ---------- id : str The measurement identifier. Raises ------ KeyError If id not in existing Measurements. """ del self._measurements[id] self._set_model_files_used()
[docs] def read_models(self, unit=u.dimensionless_unscaled): """Given a list of measurement IDs, find and open the FITS files with matching ratios and populate ``_modelratios``. Uses :class:`pdrtpy.measurement.Measurement` as a storage mechanism. Parameters ---------- unit : str or :class:`astropy.units.Unit`, optional Units of the data. """ self._modelratios = self._modelset.get_models(self.measurementIDs, model_type="ratio") if self.ratiocount < 2: msg = ( "Not enough ratios. You need to provide at least 3 observations that can be used to compute 2 ratios" f" that are covered by the ModelSet. From your observations, {self.ratiocount:d} ratio(s)" ) if self.ratiocount > 0: msg += f" {list(self._modelratios.keys())}" msg += " can be computed." raise Exception(msg) k = utils.firstkey(self._modelratios) self._modelnaxis = self._modelratios[k].wcs.naxis self._modelshape = np.array(self._modelratios[k].data.shape) if not self.density_unit: self.density_unit = self._modelratios[k].wcs.wcs.cunit[0] self.density_type = self._modelratios[k].wcs.wcs.ctype[0] if not self.radiation_field_unit: self.radiation_field_unit = self._modelratios[k].wcs.wcs.cunit[1] self.radiation_field_type = self._modelratios[k].wcs.wcs.ctype[1] # for wk2006 models, Habing units in the Y axis cause problems when trying to assign them # to a WCS (see note in modelset.py). So get from header instead. if self.radiation_field_unit.to_string() == "": try: self.radiation_field_unit = u.Unit(self._modelratios[k].header["CUNIT2"]) except KeyError as err: raise Exception( f"Keyword CUNIT2 is required in file {self._model_files_used[k]} FITS header to describe units" " of interstellar radiation field" ) from err if not self._check_model_shapes(): warnings.warn("Trimming all model grids to match H2 grid: log(n) = 1-5, log(G0) = 1-5", stacklevel=2) utils._trim_all_to_H2(self._modelratios)
def _normalize_phase_space_limits(self, radiation_field_range, density_range): """Normalize and validate user-supplied phase-space limits (issue #236). Converts ``radiation_field_range`` and ``density_range`` into ``(lower, upper)`` tuples of plain floats in the model's linear axis units, substituting the model grid extremes for any ``None`` bound. Results are stored in :attr:`_radiation_field_range` and :attr:`_density_range` for use by the coarse grid search and the refine step. Must be called after :meth:`read_models` so that ``density_unit`` / ``radiation_field_unit`` are set. Parameters ---------- radiation_field_range : :class:`~astropy.units.Quantity`, sequence, or None Allowed radiation field window; see :meth:`run`. density_range : :class:`~astropy.units.Quantity`, sequence, or None Allowed density window; see :meth:`run`. Raises ------ ValueError If a range is malformed, has incompatible units, or does not overlap the model grid. """ fk = utils.firstkey(self._modelratios) # linear (not log) physical axis values with units: x=density, y=radiation field x, y = utils.get_xy_from_wcs(self._modelratios[fk], quantity=True, linear=True) self._density_range = self._normalize_one_range(density_range, x, self.density_unit, "density_range") self._radiation_field_range = self._normalize_one_range( radiation_field_range, y, self.radiation_field_unit, "radiation_field_range" ) @staticmethod def _normalize_one_range(user_range, axis, unit, name): """Normalize a single phase-space limit to ``(lower, upper)`` floats. Parameters ---------- user_range : :class:`~astropy.units.Quantity`, sequence, or None The user-supplied range. A length-2 Quantity, or a length-2 sequence whose elements are each ``None`` or a scalar Quantity. ``None`` (or a ``None`` element) means no limit on that side. axis : :class:`~astropy.units.Quantity` The model grid axis values (linear, with units) for this parameter. unit : :class:`~astropy.units.Unit` The model's linear axis unit to convert the bounds into. name : str Keyword name, used in error messages. Returns ------- tuple of float ``(lower, upper)`` in ``unit``, with grid extremes substituted for unspecified bounds. """ grid_min = float(np.min(axis.to(unit).value)) grid_max = float(np.max(axis.to(unit).value)) if user_range is None: return None # unrestricted # Extract the two bounds, each a scalar Quantity or None. if isinstance(user_range, u.Quantity): if user_range.isscalar or len(user_range) != 2: raise ValueError(f"{name} must have exactly two elements [lower, upper]") bounds = [user_range[0], user_range[1]] else: try: bounds = list(user_range) except TypeError as err: raise ValueError(f"{name} must be a length-2 Quantity or sequence [lower, upper]") from err if len(bounds) != 2: raise ValueError(f"{name} must have exactly two elements [lower, upper]") limits = [grid_min, grid_max] # defaults substituted for None bounds for i, b in enumerate(bounds): if b is None: continue if not isinstance(b, u.Quantity): raise ValueError( f"{name} bounds must be astropy Quantities (with units); got {b!r}. For a one-sided limit put" " the unit inside the brackets, e.g. [None, 1e4/u.cm**3]." ) try: limits[i] = float(b.to(unit).value) except u.UnitConversionError as err: raise ValueError(f"{name} bound {b} is not convertible to model units ({unit})") from err lo, hi = limits if lo > hi: raise ValueError(f"{name} lower bound ({lo}) exceeds upper bound ({hi})") if hi < grid_min or lo > grid_max: raise ValueError( f"{name} [{lo}, {hi}] {unit} does not overlap the model grid coverage [{grid_min}, {grid_max}] {unit}" ) return (lo, hi) def _check_compatibility(self): """Check that all Measurements are compatible (beams, coordinate systems, shapes) so that the computation can commence. Raises ------ Exception If headers and shapes don't match; warns if no beam present. """ if not self._check_measurement_shapes(): raise Exception("Your input Measurements have different dimensions") # Check the beam sizes # @Todo do the convolution ourselves if requested. if not self._check_header("BMAJ"): raise Exception( "Beam major axis (BMAJ) of your input Measurements do not match. Please convolve all maps to the same" " beam size" ) if not self._check_header("BMIN"): raise Exception( "Beam minor axis (BMIN) of your input Measurements do not match. Please convolve all maps to the same" " beam size" ) if not self._check_header("BPA"): raise Exception( "Beam position angle (BPA) of your input Measurements do not match. Please convolve all maps to the" " same beam size" ) # Check the coordinate systems only if there is more than one pixel m1 = self._measurements[utils.firstkey(self._measurements)] if utils.is_image(m1): if not self._check_header("CTYPE1"): raise Exception( "CTYPE1 of your input Measurements do not match. Please ensure coordinates of all Measurements are" " the same." ) if not self._check_header("CTYPE2"): raise Exception( "CTYPE2 of your input Measurements do not match. Please ensure coordinates of all Measurements are" " the same." ) # Only allow beam = None if single value measurements. # if not utils.is_image(m1): # if self._check_header("BMAJ", None) or self._check_header("BMIN", None) or self._check_header("BPA", None): # utils.warn(self, "No beam parameters in Measurement headers, assuming they are all equal.") # if not self._check_header("BUNIT") ...
[docs] def run(self, **kwargs): """Run the full computation using all the added observations. Checks compatibility of input observations (beam parameters, coordinate types, axes lengths) and raises exceptions if they don’t match. Parameters ---------- mask : list or None, optional Indicate how to mask image observations before computing density and radiation field. Possible values: - ``[‘mad’, multiplier]`` — mask values between +/- multiplier*mad_std - ``[‘data’, (low, high)]`` — mask data values between low and high - ``[‘clip’, (low, high)]`` — mask data values outside [low, high] - ``[‘error’, (low, high)]`` — mask where error pixel is below low or above high - ``None`` — no masking (default) radiation_field_range : :class:`~astropy.units.Quantity` or sequence, optional Restrict the fit to radiation field values within ``[lower, upper]`` (inclusive), excluding unphysical regimes. Accepts either a length-2 :class:`~astropy.units.Quantity` (e.g. ``[100, 1000]*habing_unit``) or a length-2 sequence whose elements are each ``None`` or a scalar :class:`~astropy.units.Quantity` (e.g. ``[None, 1000*habing_unit]`` for an upper limit only). ``None`` on a side means no limit there. Note the unit must be *inside* the brackets for a one-sided limit, since ``None*unit`` is not allowed. Default: ``None`` (no restriction). density_range : :class:`~astropy.units.Quantity` or sequence, optional Restrict the fit to density values within ``[lower, upper]`` (inclusive). Same accepted forms as ``radiation_field_range``, e.g. ``[1e3, 1e4]/u.cm**3`` or ``[None, 1e4/u.cm**3]``. ``None`` on a side means no limit there. Default: ``None`` (no restriction). method : str, optional Fitting method. Default: ``’leastsq’`` (Levenberg-Marquardt). See https://lmfit-py.readthedocs.io/en/latest/fitting.html#fit-methods-table. nan_policy : str, optional Action if fit returns NaN. One of ``’raise’`` (default), ``’propagate’``, ``’omit’``. workers : int or None, optional Worker processes for parallel pixel fitting. ``None`` uses serial fitting. ``-1`` uses all CPUs. Ignored for single-pixel fits, emcee, and when ``joint_fit`` is not None. Default: None. joint_fit : str or None, optional Controls joint pixel fitting: - ``None`` (default): serial or parallel per-pixel fitting. - ``’hybrid’``: joint scipy fit + single-pixel re-fits for boundary pixels. ~3× faster than ``workers=-1``. - ``’fast’``: joint scipy fit only, no post-processing. ~11× faster but ~7–9% accuracy loss near boundaries. All joint-fit modes use TRF. Ignored for single-pixel fits and emcee. Raises ------ Exception If no models match the input observations, observations are incompatible, parameters are unrecognized, or NaN is encountered. ValueError If ``radiation_field_range`` or ``density_range`` is malformed, has the wrong units, or specifies a window that does not overlap the model grid. """ # @todo global masking for 'data', 'clip', 'error' not entirely useful unless all data/error have same ranges. # need something like ['data',['key1':(low,hi), 'key2',(low,hi),...], which is very complicated. # or data/error cut based on histogram kwargs_opts = { "mask": None, "method": "leastsq", "nan_policy": "raise", "refine": True, # phase-space limits (issue #236) "radiation_field_range": None, "density_range": None, # for emcee "burn": 0, "steps": 1000, # parallelism "workers": None, # joint fitting: None | 'hybrid' | 'fast' "joint_fit": None, # debugging "test": False, "profile": False, } kwargs_opts.update(kwargs) profile = kwargs_opts.pop("profile") self._stats = None if profile: pr = cProfile.Profile() pr.enable() self._check_compatibility() self._ratiocount = None self.read_models() # Normalize and validate user phase-space limits now that model axis # units/extents are known (read_models sets density_unit/radiation_field_unit). # Raises ValueError up front if a range does not overlap the grid. self._normalize_phase_space_limits(kwargs_opts.pop("radiation_field_range"), kwargs_opts.pop("density_range")) self._reset_masks() self._mask_measurements(kwargs_opts["mask"]) kwargs_opts.pop("mask") self._compute_valid_ratios() if self.ratiocount == 0: raise Exception("No models were found that match your data. Check ModelSet.supported_ratios.") # Pre-flatten observed ratio data and errors once so _residual_single_pixel # does not call flatten() on every minimizer iteration. self._observedratios_flat = { k: (v.data.flatten(), v.uncertainty.array.flatten()) for k, v in self._observedratios.items() } # eventually need to check that the maps overlap in real space. self._compute_residual() self._nan_policy = kwargs_opts["nan_policy"] self._minimizer = Minimizer(self._residual_single_pixel, params=None, nan_policy=self._nan_policy) # need to pop nan_policy and test so that it does not get passed to Minimzer.minimize() kwargs_opts.pop("nan_policy", None) kwargs_opts.pop("test", None) self._compute_chisq() self._coarse_density_radiation_field() if kwargs_opts["refine"]: kwargs_opts.pop("refine") joint_fit = kwargs_opts.pop("joint_fit") self._refine_density_radiation_field(joint_fit=joint_fit, **kwargs_opts) if profile: pr.disable() s = io.StringIO() sortby = pstats.SortKey.CUMULATIVE ps = pstats.Stats(pr, stream=s).sort_stats(sortby) ps.print_stats() self._stats = s
def _reset_masks(self): for m in self._measurements: self._measurements[m].mask = deepcopy(self._masks[m]) def _mask_measurements(self, mask): """Set the mask on the measurements based on noise characteristics. Prevents computing garbage n,G0 where observed ratios are noise / noise. Parameters ---------- mask : list or None Indicate how to mask image observations. See :meth:`run` for valid values. """ if mask is None: return if self._measurementnaxis == 0: utils.warn(self, "Ignoring 'mask' parameter for single pixel observations") return if mask[0] == "mad": for k, v in self._measurements.items(): sigcut = mask[1] * astats.mad_std(v.data, ignore_nan=True) print(f"Masking {k} data between [{-sigcut:.1e},{sigcut:.1e}]") masked_data = ma.masked_inside(v.data, -sigcut, sigcut, copy=True) # CCDData/NDData do not use MaskArrays underneath but two nddata.arrays. Why?? # Make a copy so we are keeping references to data copies lying around. v.mask = masked_data.mask.copy() elif mask[0] == "data": for k, v in self._measurements.items(): masked_data = ma.masked_inside(v.data, mask[1][0], mask[1][1], copy=True) v.mask = masked_data.mask.copy() print(f"Masking {k} data between [{mask[1][0]:.1e},{mask[1][1]:.1e}]") elif mask[0] == "clip": for k, v in self._measurements.items(): masked_data = ma.masked_outside(v.data, mask[1][0], mask[1][1], copy=True) v.mask = masked_data.mask.copy() print(f"Masking {k} data outside [{mask[1][0]:.1e},{mask[1][1]:.1e}]") elif mask[0] == "error": for k, v in self._measurements.items(): # error is StdDevUncertainty so must use _array to get at raw values indices = np.where((v.error <= mask[1][0]) | (v.error >= mask[1][1])) if v.mask is not None: v.mask[indices] = True else: v.mask = np.full(v.data.shape, False) v.mask[indices] = True print(f"Masking {k} data where error outside [{mask[1][0]:.1e},{mask[1][1]:.1e}]") else: raise ValueError(f"Unrecognized mask parameter {mask[0]}. Valid values are 'mad','data','error'") def _compute_valid_ratios(self): """Compute the valid observed ratio maps for the available model data""" if not self._check_measurement_shapes(): raise Exception("Measurement maps have different dimensions") # Note _find_ratio_elements does not handle case of OI+CII/FIR so # we have to deal with that separately below. z = self._modelset._find_ratio_elements(self.measurementIDs) self._observedratios = dict() for p in z: label = p["numerator"] + "/" + p["denominator"] # deepcopy workaround for bug: https://github.com/astropy/astropy/issues/9006 num = utils.convert_if_necessary(self._measurements[p["numerator"]]) denom = utils.convert_if_necessary(self._measurements[p["denominator"]]) self._observedratios[label] = deepcopy(num / denom) # @TODO create a meaningful header for the ratio map self._ratioHeader(p["numerator"], p["denominator"], label) self._observedshape = self._observedratios[label].data.shape self._observedshape = np.array(self._observedshape) self._add_oi_cii_fir() def _add_oi_cii_fir(self): """add special case ([O I] 63 micron + [C II] 158 micron)/IFIR to observed ratios""" m = self.measurementIDs if "CII_158" in m and "FIR" in m: if "OI_63" in m: lab = "OI_63+CII_158/FIR" oi = utils.convert_if_necessary(self._measurements["OI_63"]) cii = utils.convert_if_necessary(self._measurements["CII_158"]) a = deepcopy(oi + cii) b = deepcopy(self._measurements["FIR"]) self._observedratios[lab] = a / b self._observedratios[lab].meta = deepcopy(b.header) self._ratioHeader("OI_63+CII_158", "FIR", lab) if "OI_145" in m: lab = "OI_145+CII_158/FIR" oi = utils.convert_if_necessary(self._measurements["OI_145"]) cii = utils.convert_if_necessary(self._measurements["CII_158"]) aa = deepcopy(oi + cii) bb = deepcopy(self._measurements["FIR"]) self._observedratios[lab] = aa / bb self._observedratios[lab].meta = deepcopy(bb.header) self._ratioHeader("OI_145+CII_158", "FIR", lab) # function to minimize in single-pixel case def _residual_single_pixel(self, params, index): parvals = params.valuesdict() mvalue = np.empty(self.ratiocount) dvalue = np.empty(self.ratiocount) evalue = np.empty(self.ratiocount) i = 0 # @todo create temporary model, data, error as np arrays so the loop over k dictionary key # isn't necessary. # OR save residuals from computeDelta so you don't do this twice. for k in self._modelratios: mvalue[i] = self._modelratios[k].get(parvals["density"], parvals["radiation_field"]) dvalue[i] = self._observedratios_flat[k][0][index] evalue[i] = self._observedratios_flat[k][1][index] i = i + 1 return (dvalue - mvalue) / evalue def _residual_multi_pixel(self, params, index): # this is currently slower than the 'dumb' way of residual_single_pixel! parvals = params.valuesdict() return self._interp_resid(parvals["density"], parvals["radiation_field"], index) def _compute_residual(self): """Compute the residual values from the observed ratios and models""" if self.ratiocount < 2: msg = ( "Not enough ratios. You need to provide at least 3 observations that can be used to compute 2 ratios" f" that are covered by the ModelSet. From your observations, {self.ratiocount:d} ratio(s)" ) if self.ratiocount > 0: msg += f" {list(self._modelratios.keys())}" msg += " can be computed." raise Exception(msg) if not self._check_ratio_shapes(): raise Exception("Observed ratio maps have different dimensions") self._residual = dict() for r in self._observedratios: modelpix = self._modelratios[r].data.flatten() mdata = ma.masked_invalid(self._observedratios[r].value) merror = ma.masked_invalid(self._observedratios[r].error) # Vectorized: broadcast modelpix over all spatial pixels at once. # modelpix shape: (n_model_pix,); mdata shape: (ny, nx) or scalar. # modelpix_exp shape: (n_model_pix, 1, ...) to broadcast against mdata. # result shape: (n_model_pix, ny, nx) or (n_model_pix,). modelpix_exp = modelpix.reshape((-1,) + (1,) * mdata.ndim) residuals_arr = ma.masked_invalid((mdata[np.newaxis, ...] - modelpix_exp) / merror[np.newaxis, ...]) # result order is g0,n,y,x # Catch the case of a single pixel if self._observedratios[r].is_single_pixel(): newshape = np.hstack(self._modelratios[r].shape) _meta = deepcopy(self._modelratios[r].meta) _wcs = deepcopy(self._modelratios[r].wcs) # clean potential crap _meta.pop("", None) _meta.pop("TITLE", None) else: newshape = np.hstack((self._modelratios[r].shape, self._observedratios[r].shape)) _meta = deepcopy(self._observedratios[r].meta) _wcs = deepcopy(self._observedratios[r].wcs) # result order is g0,n,y,x _qq = np.squeeze(np.reshape(residuals_arr, newshape)) self._residual[r] = CCDData(_qq, unit="adu", wcs=_wcs, meta=_meta) self._fancy_index_residual() def _fancy_index_residual(self): # create a custom numpy array for interpolating the residuals # during fitting. fk = utils.firstkey(self._modelratios) resid_index = np.array(np.arange(len(self._residual))) # use linear interpolation grid from model rad_index = self._modelratios[fk]._world_axis_lin[1] density_index = self._modelratios[fk]._world_axis_lin[0] image_index = np.arange(self._observedratios[fk].size) self._interpgrid = (resid_index, rad_index, density_index, image_index) # put the residual data into a single numpy array self._interpvalues = np.stack([self._residual[r].data for r in self._residual.keys()]) # the flatten the spatial indices, so indices are then [radiation_field,density,pixel] self._interpvalues = self._interpvalues.reshape(*self._interpvalues.shape[:-2], -1) def _interp_resid(self, density, radiation_field, pixel): # density and radiation field must be linear not logarithmic values. # pixel is 0-based pixel index into flattened map data array. vals = list() for i in range(len(self._residual)): vals.append(interpn(self._interpgrid, self._interpvalues, (i, radiation_field, density, pixel))) return np.array(vals) def _compute_chisq(self): """Compute the chi-squared values from observed ratios and models""" if self.ratiocount < 2: raise Exception(f"Not enough ratios to compute chisq. Need 2, got {self.ratiocount:d}") sumary = sum(self._residual[r]._data ** 2 for r in self._residual) self._dof = len(self._residual) - 1 k = utils.firstkey(self._residual) _wcs = deepcopy(self._residual[k].wcs) _meta = deepcopy(self._residual[k].meta) self._chisq = CCDData(sumary, unit="adu", wcs=_wcs, meta=_meta) self._reduced_chisq = self._chisq.divide(self._dof) # must make a copy here otherwise the header is an OrderDict # instead of astropy.io.fits.header.Header self._reduced_chisq.header = Header(deepcopy(self._chisq.header)) self._fixheader(self._chisq) self._fixheader(self._reduced_chisq) utils.comment("Chi-squared", self._chisq) utils.comment(f"Reduced Chi-squared (DOF={self._dof:d})", self._reduced_chisq) self._makehistory(self._chisq) self._makehistory(self._reduced_chisq)
[docs] def write_chisq(self, chi="chisq.fits", rchi="rchisq.fits", overwrite=True): """Write the chisq and reduced-chisq data to a file. Parameters ---------- chi : str, optional FITS file to write the chisq map to. Default: ``"chisq.fits"``. rchi : str, optional FITS file to write the reduced chisq map to. Default: ``"rchisq.fits"``. """ self._chisq.write(chi, overwrite=overwrite, hdu_mask="MASK", output_verify="silentfix") self._reduced_chisq.write(rchi, overwrite=overwrite, hdu_mask="MASK", output_verify="silentfix")
@staticmethod def _build_joint_params(valid_pixels, dflat, rflat, minn, maxn, minfuv, maxfuv): """Build an lmfit Parameters object with density_{i} and radiation_field_{i} for each valid pixel i (0-indexed over valid_pixels). Initial values come from the coarse fit (dflat[j], rflat[j]).""" params = Parameters() for i, j in enumerate(valid_pixels): params.add(f"density_{i}", min=minn, max=maxn, value=dflat[j]) params.add(f"radiation_field_{i}", min=minfuv, max=maxfuv, value=rflat[j]) return params @staticmethod def _build_jac_sparsity(n_valid, n_ratios): """Build a block-diagonal CSR sparsity matrix for joint pixel fitting. Shape: (n_valid * n_ratios, 2 * n_valid). Pixel i's residual rows (i*n_ratios : (i+1)*n_ratios) are non-zero only at columns 2*i (density_i) and 2*i+1 (radiation_field_i).""" from scipy.sparse import lil_matrix sparsity = lil_matrix((n_valid * n_ratios, 2 * n_valid), dtype=np.int8) for i in range(n_valid): sparsity[i * n_ratios : (i + 1) * n_ratios, 2 * i : 2 * i + 2] = 1 return sparsity.tocsr() def _refine_density_radiation_field(self, joint_fit=None, **kwargs): workers = kwargs.pop("workers", None) if kwargs["method"] != "emcee": kwargs.pop("steps") kwargs.pop("burn") progress = kwargs.pop("progress", True) # progress bar use_parallel = workers is not None and workers != 1 else: progress = kwargs.get("progress", False) # keep the progress keyword for emcee, get vs pop use_parallel = False # emcee manages its own parallelism joint_fit = None # emcee manages its own parallelism # First get the range of density n and radiation field FUV from the # model space, in order to provide them to the Parameters object. # Since the wk2006 H2 models have a smaller model space, # we have to check if H2 is in one of the models used. keys = list(self._modelratios.keys()) if utils._has_H2(keys): # this will get the index for the first modelratio that has H2 in it # https://stackoverflow.com/questions/2170900/get-first-list-index-containing-sub-string i = next(idx for idx, s in enumerate(keys) if "H2" in s) fk = keys[i] else: fk = keys[0] # this will work regardless if Y axis is Habing, CGS, Draine, etc # We do this in linear space because we want the fit report to be in linear space # which is what observers care about. x, y = utils.get_xy_from_wcs(self._modelratios[fk], linear=True) minn = x[0] maxn = x[-1] minfuv = y[0] maxfuv = y[-1] # Clamp bounds to the user's phase-space window (issue #236). These four # scalars feed the serial, parallel, and joint refine paths, so this # single clamp restricts them all. if self._density_range is not None: minn = max(minn, self._density_range[0]) maxn = min(maxn, self._density_range[1]) if self._radiation_field_range is not None: minfuv = max(minfuv, self._radiation_field_range[0]) maxfuv = min(maxfuv, self._radiation_field_range[1]) if self._radiation_field is None or self._density is None: startn = x[int(len(x) / 2)] startfuv = y[int(len(y) / 2)] else: startn = np.nanmean(self._density.value) startfuv = np.nanmean(self._radiation_field.value) self._fitparam = Parameters() # @todo let user limit these ranges. either they pass in Parameters or # limits = {'density':[low,hi], 'radiation_field':[low,hi]} # in any unit and convert self._fitparam.add("density", min=minn, max=maxn, value=startn) self._fitparam.add("radiation_field", min=minfuv, max=maxfuv, value=startfuv) # self._fitparam.pretty_print() rf = np.empty(self._observedratios[fk].size) den = np.empty(self._observedratios[fk].size) rfe = np.empty(self._observedratios[fk].size) dene = np.empty(self._observedratios[fk].size) chi = np.empty(self._observedratios[fk].size) rchi = np.empty(self._observedratios[fk].size) dflat = self._density.value.flatten() rflat = self._radiation_field.value.flatten() fmdata = np.empty(self._observedratios[fk].size, dtype=object) fm_mask = np.full(shape=self._observedratios[fk].data.shape, fill_value=False).flatten() count = 0 excount = 0 # turn off progress bar for single pixel or emcee prints out multiple bars. if self._observedratios[fk].size == 1: progress = False use_parallel = False joint_fit = None if joint_fit in ("hybrid", "fast"): # ------------------------------------------------------------------ # Joint pixel fitting: single scipy.optimize.least_squares # call for all pixels with block-diagonal jac_sparsity. # Bypasses lmfit to avoid its sparse-Jacobian covariance bug # (element-wise * instead of matrix @ when computing J^T J). # ------------------------------------------------------------------ ratio_keys = list(self._modelratios.keys()) n_ratios = self.ratiocount interps = [self._modelratios[k]._interp_lin for k in ratio_keys] valid_mask = ~(np.isnan(dflat) | np.isnan(rflat)) valid_pixels = np.where(valid_mask)[0] # indices into flattened map n_valid = len(valid_pixels) obs_data = np.array( [self._observedratios_flat[k][0][valid_pixels] for k in ratio_keys] ) # (n_ratios, n_valid) obs_err = np.array( [self._observedratios_flat[k][1][valid_pixels] for k in ratio_keys] ) # (n_ratios, n_valid) # Residual takes a plain numpy array: x[::2]=density, x[1::2]=rf def _joint_residual(x): pts = np.column_stack([x[::2], x[1::2]]) # (n_valid, 2) mvalues = np.array([interp(pts) for interp in interps]) # (n_ratios, n_valid) return ((obs_data - mvalues) / obs_err).flatten() # (n_valid * n_ratios,) # Build x0 (initial guess) and bounds from coarse-fit values x0 = np.empty(2 * n_valid) x0[::2] = dflat[valid_pixels] x0[1::2] = rflat[valid_pixels] lb = np.empty(2 * n_valid) ub = np.empty(2 * n_valid) lb[::2] = minn ub[::2] = maxn lb[1::2] = minfuv ub[1::2] = maxfuv sparsity = self._build_jac_sparsity(n_valid, n_ratios) joint_result = _scipy_least_squares( _joint_residual, x0, bounds=(lb, ub), jac_sparsity=sparsity, tr_solver="lsmr", method="trf", ) # Mark NaN/invalid pixels as masked fm_mask[~valid_mask] = True den[~valid_mask] = dene[~valid_mask] = np.nan rf[~valid_mask] = rfe[~valid_mask] = np.nan chi[~valid_mask] = rchi[~valid_mask] = np.nan # Compute per-pixel stderr from block-diagonal Jacobian blocks. # J_i = jac[i*n_ratios:(i+1)*n_ratios, 2*i:2*i+2] shape (n_ratios, 2) # cov_i = inv(J_i.T @ J_i); stderr = sqrt(diag(cov_i)) jac = joint_result.jac # sparse or dense all_resid = joint_result.fun.reshape(n_valid, n_ratios) for i, j in enumerate(valid_pixels): den[j] = joint_result.x[2 * i] rf[j] = joint_result.x[2 * i + 1] chi[j] = float(np.sum(all_resid[i] ** 2)) rchi[j] = chi[j] / max(n_ratios - 1, 1) # Per-pixel stderr from local Jacobian block r0, r1 = i * n_ratios, (i + 1) * n_ratios c0, c1 = 2 * i, 2 * i + 2 J_i = jac[r0:r1, c0:c1] if hasattr(J_i, "toarray"): J_i = J_i.toarray() try: JTJ = J_i.T @ J_i cov_i = np.linalg.inv(JTJ) stderr_den = float(np.sqrt(max(cov_i[0, 0], 0.0))) stderr_rf = float(np.sqrt(max(cov_i[1, 1], 0.0))) except np.linalg.LinAlgError: stderr_den = stderr_rf = None dene[j] = stderr_den rfe[j] = stderr_rf fmdata[j] = _PixelResult( den[j], dene[j], rf[j], rfe[j], chi[j], rchi[j], n_ratios, joint_result.success, all_resid[i], ) count += 1 # ------------------------------------------------------------------ # Hybrid post-processing ('hybrid' only, skipped for 'fast'): # Re-fit pixels that did not move from their coarse initial guess. # These are typically pixels initialised at a model boundary where # the global LSMR convergence criterion is satisfied before the # local 2-parameter problem for that pixel is resolved. # ------------------------------------------------------------------ if joint_fit == "hybrid": stuck = np.isclose(joint_result.x[::2], x0[::2], rtol=1e-4, atol=0) & np.isclose( joint_result.x[1::2], x0[1::2], rtol=1e-4, atol=0 ) print(f"Refitting {len(stuck)} stuck pixels...") for i in np.where(stuck)[0]: j = valid_pixels[i] obs_j = obs_data[:, i] err_j = obs_err[:, i] def _sp_resid(x, _obs=obs_j, _err=err_j): pts = x.reshape(1, 2) mvals = np.array([interp(pts)[0] for interp in interps]) return (_obs - mvals) / _err r = _scipy_least_squares( _sp_resid, [x0[2 * i], x0[2 * i + 1]], bounds=([minn, minfuv], [maxn, maxfuv]), method="trf", ) den[j] = r.x[0] rf[j] = r.x[1] resid_i = r.fun chi[j] = float(np.sum(resid_i**2)) rchi[j] = chi[j] / max(n_ratios - 1, 1) J_i = r.jac try: cov_i = np.linalg.inv(J_i.T @ J_i) stderr_den = float(np.sqrt(max(cov_i[0, 0], 0.0))) stderr_rf = float(np.sqrt(max(cov_i[1, 1], 0.0))) except np.linalg.LinAlgError: stderr_den = stderr_rf = None dene[j] = stderr_den rfe[j] = stderr_rf fmdata[j] = _PixelResult( den[j], dene[j], rf[j], rfe[j], chi[j], rchi[j], n_ratios, r.success, resid_i, ) elif use_parallel: # ------------------------------------------------------------------ # Parallel pixel loop using ProcessPoolExecutor. # Model interpolators are built once per worker process by # _init_worker to avoid re-sending large model arrays per task. # ------------------------------------------------------------------ ratio_keys = list(self._modelratios.keys()) model_points = [self._modelratios[k]._world_axis_lin for k in ratio_keys] model_values = [self._modelratios[k].data.T for k in ratio_keys] obs_data_arr = np.array([self._observedratios_flat[k][0] for k in ratio_keys]) obs_err_arr = np.array([self._observedratios_flat[k][1] for k in ratio_keys]) nan_policy = getattr(self, "_nan_policy", "raise") max_workers = None if workers == -1 else workers futures = {} with ProcessPoolExecutor( max_workers=max_workers, initializer=_init_worker, initargs=(model_points, model_values), ) as pool: for j in range(self._observedratios[fk].size): if np.isnan(dflat[j]) or np.isnan(rflat[j]): fmdata[j] = None fm_mask[j] = True den[j] = dene[j] = rf[j] = rfe[j] = chi[j] = rchi[j] = np.nan else: futures[ pool.submit( _fit_pixel_worker, j, obs_data_arr[:, j], obs_err_arr[:, j], dflat[j], rflat[j], minn, maxn, minfuv, maxfuv, nan_policy, dict(kwargs), ) ] = j with get_progress_bar(progress, len(futures), leave=True, position=0) as pbar: for fut in as_completed(futures): j = futures[fut] try: _, result = fut.result() fmdata[j] = result count += 1 den[j] = result.params["density"].value dene[j] = result.params["density"].stderr rf[j] = result.params["radiation_field"].value rfe[j] = result.params["radiation_field"].stderr chi[j] = result.chisqr rchi[j] = result.redchi except ValueError: excount += 1 fmdata[j] = None fm_mask[j] = True den[j] = dene[j] = rf[j] = rfe[j] = chi[j] = rchi[j] = np.nan pbar.update(1) else: # ------------------------------------------------------------------ # Serial pixel loop (default). # ------------------------------------------------------------------ with get_progress_bar(progress, self._observedratios[fk].size, leave=True, position=0) as pbar: for j in range(self._observedratios[fk].size): # use previous coarse fit as first guess self._fitparam["density"].value = dflat[j] self._fitparam["radiation_field"].value = rflat[j] if np.isnan(dflat[j]) or np.isnan(rflat[j]): fmdata[j] = None fm_mask[j] = True den[j] = np.nan dene[j] = np.nan rfe[j] = np.nan rf[j] = np.nan chi[j] = np.nan rchi[j] = np.nan else: try: self._minimizer.userargs = (j,) fmdata[j] = self._minimizer.minimize(params=self._fitparam, **kwargs) # if hasattr(fmdata[j],"success") ugh. not guaranteed # if fmdata[j].errorbars: count = count + 1 den[j] = fmdata[j].params["density"].value dene[j] = fmdata[j].params["density"].stderr rf[j] = fmdata[j].params["radiation_field"].value rfe[j] = fmdata[j].params["radiation_field"].stderr chi[j] = fmdata[j].chisqr rchi[j] = fmdata[j].redchi # else: # fmdata[j] = None # fm_mask[j] = True except ValueError: # print("At pixel %d, got valuerror %s with fitparams %s" %(j, exc,self._fitparam)) excount = excount + 1 fmdata[j] = None fm_mask[j] = True den[j] = np.nan dene[j] = np.nan rfe[j] = np.nan rf[j] = np.nan chi[j] = np.nan rchi[j] = np.nan pbar.update(1) fmdata = fmdata.reshape(self._observedratios[fk].data.shape) fm_mask = fm_mask.reshape(self._observedratios[fk].data.shape) # ff_mask = ff_mask | np.logical_not(np.isfinite(/*something*/)) self._fitresult = FitMap(fmdata, wcs=self._observedratios[fk].wcs, mask=fm_mask, name="result") # print(f"fitted {count} of {self._observedratios[fk].size} pixels") # print(f"got {excount} exceptions") rshape = self._radiation_field.data.shape dshape = self._density.data.shape self._radiation_field.data = rf.reshape(rshape) self._radiation_field.uncertainty.array = rfe.reshape(rshape) self._density.data = den.reshape(dshape) self._density.uncertainty.array = dene.reshape(dshape) self._chisq_min.data = chi.reshape(dshape) self._reduced_chisq_min.data = rchi.reshape(dshape) def _coarse_density_radiation_field(self): """Compute the best-fit density and radiation field spatial maps by searching for the minimum chi-squared at each spatial pixel.""" if self._chisq is None or self._reduced_chisq is None: return # get the chisq minima of each pixel along the g,n axes fk = utils.firstkey(self._modelratios) mshape = self._modelratios[fk].shape # Wolfire 2006 models have NAXIS=2, while 2020+ have NAXIS=3. # Deal with it. # @see Measurement squeeze parameter. This should no longer be needed if self._modelnaxis == 2: firstindex = 0 secondindex = 1 thirdindex = 2 fourthindex = 3 elif self._modelnaxis == 3: if mshape[0] != 1: raise Exception(f"Unexpected NAXIS3 != 1 in model {fk}") firstindex = 1 secondindex = 2 thirdindex = 3 fourthindex = 4 # Restrict the coarse grid search to the allowed phase-space window # (issue #236). Out-of-range model cells are set to +inf on local copies # (the stored full chisq cubes are left untouched) so the argmin below # is confined to the physical region. Validation in # _normalize_phase_space_limits guarantees >=1 in-range cell survives. allowed = self._phase_space_grid_mask(self._reduced_chisq.data.ndim, firstindex, secondindex) if allowed is None: masked_rchi = self._reduced_chisq.data masked_chi = self._chisq.data else: masked_rchi = np.where(allowed, self._reduced_chisq.data, np.inf) masked_chi = np.where(allowed, self._chisq.data, np.inf) rchi_min = np.amin(masked_rchi, (firstindex, secondindex)) chi_min = np.amin(masked_chi, (firstindex, secondindex)) gnxy = np.where(masked_rchi == rchi_min) gi = gnxy[firstindex] ni = gnxy[secondindex] # spatial_idx is which indices are the spatial axes if len(gnxy) >= 4: # astronomical spatial indices spatial_idx = (gnxy[thirdindex], gnxy[fourthindex]) else: spatial_idx = 0 # model n,g0 indices model_idx = np.transpose(np.array([ni, gi])) if self._modelnaxis == 3: # add 3rd axis to model_idx model_idx = np.insert(model_idx, 0, [0], axis=1) fk2 = utils.firstkey(self._observedratios) newshape = self._observedratios[fk2].shape g0 = 10 ** (self._modelratios[fk].wcs.wcs_pix2world(model_idx, 0))[:, 1] n = 10 ** (self._modelratios[fk].wcs.wcs_pix2world(model_idx, 0))[:, 0] self._radiation_field = deepcopy(self._observedratios[fk2]) if spatial_idx == 0 and newshape == (1,): self._radiation_field.data = g0 self._radiation_field.uncertainty.array = np.array([np.nan]) else: if self.has_vectors: self._radiation_field.data = g0 else: # Measurement with image # note this will reshape g0 in radiation_field for us! self._radiation_field.data[spatial_idx] = g0 # We cannot mask nans because numpy does not support writing # MaskedArrays to a file. Will get a not implemented error. # Therefore just copy the nans over from the input observations. self._radiation_field.data[np.isnan(self._observedratios[fk2])] = np.nan # kluge because we dont know how to properly calcultate uncertainty on this. # self._radiation_field.uncertainty.array=np.zeroes(self._radiation_field.uncertainty.array) self._radiation_field.uncertainty.array[:] = np.nan self._radiation_field.unit = self.radiation_field_unit self._radiation_field.uncertainty.unit = self.radiation_field_unit self._density = deepcopy(self._observedratios[fk2]) if spatial_idx == 0 and newshape == (1,): self._density.data = n self._density.uncertainty.array = np.array([np.nan]) else: if self.has_vectors: # Measurement with data vector self._density.data = n else: # Measurement with image # note this will reshape g0 in radiation_field for us! self._density.data[spatial_idx] = n self._density.data[np.isnan(self._observedratios[fk2])] = np.nan # kluge because we dont know how to properly calcultate undertainty on this. # self._density.uncertainty.array=np.zeroes(self._density.uncertainty.array) self._density.uncertainty.array[:] = np.nan self._density.unit = self.density_unit self._density.uncertainty.unit = self.density_unit # this raises exception, CCDData enforces both units the same # self._density.uncertainty.unit = u.dimensionless_unscaled # fix the headers self._density_radiation_field_header() # now save copies of the 2D min chisquares self._chisq_min = deepcopy(self._observedratios[fk2]) if spatial_idx == 0 and newshape == (1,): self._chisq_min.data = np.array([chi_min]) elif self._modelnaxis == 2: self._chisq_min.data = chi_min else: self._chisq_min.data = chi_min[0, :, :] self._chisq_min.data[np.isnan(self._observedratios[fk2])] = np.nan self._chisq_min.unit = u.dimensionless_unscaled self._chisq_min.uncertainty.array = [0.0] self._chisq_min.uncertainty.unit = u.dimensionless_unscaled self._reduced_chisq_min = deepcopy(self._observedratios[fk2]) if spatial_idx == 0 and newshape == (1,): self._reduced_chisq_min.data = np.array([rchi_min]) else: if self._modelnaxis == 2: self._reduced_chisq_min.data = rchi_min else: self._reduced_chisq_min.data = rchi_min[0, :, :] self._reduced_chisq_min.data[np.isnan(self._observedratios[fk2])] = np.nan self._reduced_chisq_min.unit = u.dimensionless_unscaled self._reduced_chisq_min.uncertainty.array = [0.0] self._reduced_chisq_min.uncertainty.unit = u.dimensionless_unscaled # update histories utils.setkey("BUNIT", "Minimum Chi-squared", self._chisq_min) utils.setkey("BUNIT", f"Minimum Reduced Chi-squared (DOF={self._dof:d})", self._reduced_chisq_min) self._makehistory(self._reduced_chisq_min) self._makehistory(self._chisq_min) def _phase_space_grid_mask(self, ndim, firstindex, secondindex): """Boolean mask selecting model grid cells inside the allowed phase-space window (issue #236), or ``None`` if unrestricted. The returned array is broadcastable to the chi-squared cube: it is 1 along every axis except the radiation-field axis (``firstindex``) and the density axis (``secondindex``). Parameters ---------- ndim : int Number of dimensions of the chi-squared cube. firstindex : int Cube axis index of the radiation field. secondindex : int Cube axis index of the density. Returns ------- :class:`numpy.ndarray` of bool or None ``None`` when no phase-space limits are active. """ if self._density_range is None and self._radiation_field_range is None: return None fk = utils.firstkey(self._modelratios) # linear physical axis values with units: x=density, y=radiation field x, y = utils.get_xy_from_wcs(self._modelratios[fk], quantity=True, linear=True) xden = x.to(self.density_unit).value # values along secondindex yrf = y.to(self.radiation_field_unit).value # values along firstindex den_ok = np.ones(len(xden), dtype=bool) rf_ok = np.ones(len(yrf), dtype=bool) if self._density_range is not None: dlo, dhi = self._density_range den_ok = (xden >= dlo) & (xden <= dhi) if self._radiation_field_range is not None: rlo, rhi = self._radiation_field_range rf_ok = (yrf >= rlo) & (yrf <= rhi) den_shape = [1] * ndim den_shape[secondindex] = len(xden) rf_shape = [1] * ndim rf_shape[firstindex] = len(yrf) return rf_ok.reshape(rf_shape) & den_ok.reshape(den_shape) def _makehistory(self, image): """Add HISTORY keyword indicating how density and radiation field were computed. Parameters ---------- image : :class:`astropy.io.fits.ImageHDU`, :class:`astropy.nddata.CCDData`, or :class:`~pdrtpy.measurement.Measurement` The image to which to add the history. """ s = "Measurements provided: " + str(list(self._measurements.keys())) utils.history(s, image) s = "Ratios used: " + str(list(self._residual.keys())) utils.history(s, image) utils.signature(image) utils.dataminmax(image) def _ratioHeader(self, numerator, denominator, label): """Add the RATIO identifier to the appropriate image. Parameters ---------- numerator : str Numerator key of the line ratio. denominator : str Denominator key of the line ratio. label : str Ratio key indicating which observation image (Measurement) to use. """ utils.addkey("RATIO", label, self._observedratios[label]) utils.dataminmax(self._observedratios[label]) utils.signature(self._observedratios[label]) def _fixheader(self, image): """Put additional axis and header values into an image. Parameters ---------- image : :class:`astropy.io.fits.ImageHDU`, :class:`astropy.nddata.CCDData`, or :class:`~pdrtpy.measurement.Measurement` The image to which to add the header values. """ if self._modelnaxis == 2: naxis = len(image.shape) else: naxis = len(image.shape) - 1 ax1 = str(naxis - 1) ax2 = str(naxis) if "NAXIS" not in image.header: utils.setkey("NAXIS", naxis, image) utils.setkey("NAXIS" + ax1, image.shape[1], image) utils.setkey("NAXIS" + ax2, image.shape[0], image) utils.setkey("CTYPE" + ax1, self.density_type, image) utils.setkey("CTYPE" + ax2, self.radiation_field_type, image) utils.setkey("CUNIT" + ax1, str(self.density_unit), image) utils.setkey("CUNIT" + ax2, str(self.radiation_field_unit), image) fk = utils.firstkey(self._modelratios) mod = self._modelratios[fk] utils.setkey("CDELT" + ax1, mod.wcs.wcs.cdelt[0], image) utils.setkey("CDELT" + ax2, mod.wcs.wcs.cdelt[1], image) utils.setkey("CRVAL" + ax1, mod.wcs.wcs.crval[0], image) utils.setkey("CRVAL" + ax2, mod.wcs.wcs.crval[1], image) utils.setkey("CRPIX" + ax1, mod.wcs.wcs.crpix[0], image) utils.setkey("CRPIX" + ax2, mod.wcs.wcs.crpix[1], image) def _density_radiation_field_header(self): """Common header items in the density and radiation field FITS files""" self._density.header.pop("RATIO") self._radiation_field.header.pop("RATIO") # note: must use to_string() here or astropy.io.fits.Card complains # about the value being a Unit. Oddly it doesn't complain for the # data units. Go figure. utils.setkey("BUNIT", self.density_unit.to_string(), self._density) utils.comment("Best-fit H2 volume density", self._density) utils.setkey("BUNIT", self.radiation_field_unit.to_string(), self._radiation_field) utils.comment("Best-fit interstellar radiation field", self._radiation_field) self._makehistory(self._density) self._makehistory(self._radiation_field) # convert from OrderedDict to astropy.io.fits.header.Header self._density.header = Header(self._density.header) self._radiation_field.header = Header(self._radiation_field.header) self._density._identifier = "H2 Volume Density" self._radiation_field._identifier = "Radiation Field" @property def table(self): # @TODO: make this work for map data ? r"""Construct the table of input Measurements and, if the fit has been run, the density, radiation field, and :math:`\chi^2` values. Returns ------- :class:`astropy.table.Table` """ v = self._measurements.values() # This only works for astropy version >= 4.1 # and for some reason requirements.txt did not install it # for a test user. # t = Table(self._measurements, # units=[m.unit for m in v] # ) t = Table() cols = [Column(data=d, unit=d.unit) for d in v] t.add_columns(cols=cols, names=[m.id for m in v]) if self._observedratios is not None: v = self._observedratios.values() cols = [Column(data=d, unit=d.unit) for d in v] t.add_columns(cols=cols, names=[m.id for m in v]) if self.radiation_field is not None: t.add_column(col=Column(self.radiation_field, unit=self.radiation_field_unit), name=self.radiation_field.id) if self.density is not None: t.add_column(col=Column(self.density, unit=self.density_unit), name=self.density.id) if self._chisq_min is not None: t.add_column(col=Column(self._chisq_min, unit=None), name="Chi-square") for j in t.columns: t[j].format = "3.2E" return t